Skip to main content

dynomite/conf/
pool.rs

1//! Pool body schema, default application, and validation.
2//!
3//! [`ConfPool`] is the parsed body of one server pool. Every field
4//! whose configuration value may be left unset is wrapped in
5//! [`Option`]; [`ConfPool::apply_defaults`] later fills in the
6//! defaults for the fields the operator omitted.
7
8use std::collections::BTreeMap;
9use std::fmt;
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14use super::endpoint::ConfListen;
15use super::enums::{
16    ConsistencyLevel, DataStore, Distribution, HashType, SecureServerOption, Transport,
17};
18use super::error::ConfError;
19use super::server::{ConfDynSeed, ConfServer};
20use super::tokens::TokenList;
21
22/// Default configuration constants applied when an operator omits
23/// the corresponding key.
24pub mod defaults {
25    /// Default request timeout in milliseconds.
26    pub const TIMEOUT_MS: i64 = 5_000;
27    /// Default `listen()` backlog.
28    pub const LISTEN_BACKLOG: i64 = 512;
29    /// Default `client_connections:` value (0 = unlimited).
30    pub const CLIENT_CONNECTIONS: i64 = 0;
31    /// Default `data_store:` value (0 = redis).
32    pub const DATA_STORE: i64 = 0;
33    /// Default `preconnect:` value.
34    ///
35    /// `preconnect` defaults to false: clients can connect to
36    /// dynomited before the local datastore is reachable. The lazy
37    /// connect avoids a hard dependency on boot ordering.
38    pub const PRECONNECT: bool = false;
39    /// Default `auto_eject_hosts:` value.
40    pub const AUTO_EJECT_HOSTS: bool = true;
41    /// Default `server_retry_timeout:` (ms).
42    pub const SERVER_RETRY_TIMEOUT_MS: i64 = 10 * 1000;
43    /// Default `server_failure_limit:`.
44    pub const SERVER_FAILURE_LIMIT: i64 = 3;
45    /// Default `dyn_read_timeout:` (ms).
46    pub const DYN_READ_TIMEOUT_MS: i64 = 10_000;
47    /// Default `dyn_write_timeout:` (ms).
48    pub const DYN_WRITE_TIMEOUT_MS: i64 = 10_000;
49    /// Default `dyn_connections:`.
50    pub const DYN_CONNECTIONS: i64 = 100;
51    /// Default `gos_interval:` (ms).
52    pub const GOS_INTERVAL_MS: i64 = 30_000;
53    /// Default `enable_hinted_handoff:` value. The feature is
54    /// off by default until operators opt in.
55    pub const ENABLE_HINTED_HANDOFF: bool = false;
56    /// Default `hint_ttl_seconds:` (24h). Hints older than this
57    /// are dropped during expiry sweeps.
58    pub const HINT_TTL_SECONDS: u64 = 86_400;
59    /// Default `hint_store_max_bytes:` (64 MiB) cap on the
60    /// node-local in-memory hint store.
61    pub const HINT_STORE_MAX_BYTES: u64 = 64 * 1024 * 1024;
62    /// Default `hint_drain_interval_ms:` between hint drainer
63    /// sweeps.
64    pub const HINT_DRAIN_INTERVAL_MS: u64 = 30_000;
65    /// Default per-connection message rate.
66    pub const CONN_MSG_RATE: u32 = 50_000;
67    /// Default `stats_interval:` (ms).
68    pub const STATS_INTERVAL_MS: i64 = 30 * 1000;
69    /// Default stats listener address.
70    pub const STATS_PNAME: &str = "0.0.0.0:22222";
71    /// Default datastore-side connection count.
72    pub const DATASTORE_CONNECTIONS: u8 = 1;
73    /// Default local-peer connection count.
74    pub const LOCAL_PEER_CONNECTIONS: u8 = 1;
75    /// Default remote-peer connection count.
76    pub const REMOTE_PEER_CONNECTIONS: u8 = 1;
77    /// Default rack name.
78    pub const RACK: &str = "localrack";
79    /// Default datacenter name.
80    pub const DC: &str = "localdc";
81    /// Default `secure_server_option:` value.
82    pub const SECURE_SERVER_OPTION: &str = "none";
83    /// Default `read_consistency:` / `write_consistency:`.
84    pub const CONSISTENCY: &str = "DC_ONE";
85    /// Default `dyn_seed_provider:`.
86    pub const SEED_PROVIDER: &str = "simple_provider";
87    /// Default `env:` (cloud environment marker).
88    pub const ENV: &str = "aws";
89    /// Default PEM key file path.
90    pub const PEM_KEY_FILE: &str = "conf/dynomite.pem";
91    /// Default reconciliation key file path.
92    pub const RECON_KEY_FILE: &str = "conf/recon_key.pem";
93    /// Default reconciliation IV file path.
94    pub const RECON_IV_FILE: &str = "conf/recon_iv.pem";
95    /// Default cadence (in seconds) of the entropy reconciliation
96    /// run loop. Mirrors the brief's five-minute default; ignored
97    /// when the entropy task is not enabled.
98    pub const RECON_INTERVAL_SECONDS: u64 = 300;
99    /// Smallest valid `mbuf_size:`.
100    pub const MBUF_MIN_SIZE: i64 = 512;
101    /// Largest valid `mbuf_size:`.
102    pub const MBUF_MAX_SIZE: i64 = 512_000;
103    /// Smallest valid `max_msgs:`.
104    pub const ALLOC_MSGS_MIN: i64 = 100_000;
105    /// Largest valid `max_msgs:`.
106    pub const ALLOC_MSGS_MAX: i64 = 1_000_000;
107}
108
109/// Wrapper for the `servers:` field that enforces the invariant
110/// of "exactly one datastore" without losing the YAML list shape.
111///
112/// # Examples
113///
114/// ```
115/// use dynomite::conf::{ConfServer, Servers};
116/// let s = Servers::from_vec(vec![ConfServer::parse("127.0.0.1:6379:1").unwrap()]);
117/// assert_eq!(s.len(), 1);
118/// assert!(!s.is_empty());
119/// assert!(s.datastore().is_some());
120/// ```
121#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
122#[serde(transparent)]
123pub struct Servers(pub(crate) Vec<ConfServer>);
124
125impl Servers {
126    /// Construct from an explicit list. Validation enforces a length
127    /// of one when called via `Config::validate`.
128    ///
129    /// # Examples
130    ///
131    /// ```
132    /// use dynomite::conf::{ConfServer, Servers};
133    /// let s = Servers::from_vec(vec![ConfServer::parse("127.0.0.1:6379:1").unwrap()]);
134    /// assert_eq!(s.len(), 1);
135    /// ```
136    pub fn from_vec(v: Vec<ConfServer>) -> Self {
137        Self(v)
138    }
139}
140
141impl Servers {
142    /// Borrow the entries.
143    ///
144    /// # Examples
145    ///
146    /// ```
147    /// use dynomite::conf::{ConfServer, Servers};
148    /// let s = Servers::from_vec(vec![ConfServer::parse("127.0.0.1:6379:1").unwrap()]);
149    /// assert_eq!(s.entries().len(), 1);
150    /// ```
151    pub fn entries(&self) -> &[ConfServer] {
152        &self.0
153    }
154    /// Number of entries.
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// use dynomite::conf::Servers;
160    /// assert_eq!(Servers::default().len(), 0);
161    /// ```
162    pub fn len(&self) -> usize {
163        self.0.len()
164    }
165    /// Whether the list is empty.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use dynomite::conf::Servers;
171    /// assert!(Servers::default().is_empty());
172    /// ```
173    pub fn is_empty(&self) -> bool {
174        self.0.is_empty()
175    }
176    /// The single datastore (returns the first entry, if any).
177    ///
178    /// # Examples
179    ///
180    /// ```
181    /// use dynomite::conf::{ConfServer, Servers};
182    /// let s = Servers::from_vec(vec![ConfServer::parse("127.0.0.1:6379:1").unwrap()]);
183    /// assert!(s.datastore().is_some());
184    /// assert!(Servers::default().datastore().is_none());
185    /// ```
186    pub fn datastore(&self) -> Option<&ConfServer> {
187        self.0.first()
188    }
189}
190
191/// Pool configuration body. One per top-level YAML pool name.
192///
193/// # Examples
194///
195/// ```
196/// use dynomite::conf::{ConfPool, ConfListen};
197/// let mut p = ConfPool::default();
198/// assert!(p.listen.is_none());
199/// p.listen = Some(ConfListen::parse("listen", "127.0.0.1:8102").unwrap());
200/// p.apply_defaults();
201/// assert_eq!(p.timeout, Some(5_000));
202/// ```
203#[derive(Debug, Clone, Default, Serialize, Deserialize)]
204#[serde(deny_unknown_fields, default)]
205pub struct ConfPool {
206    /// `listen:` - client-facing listener address.
207    pub listen: Option<ConfListen>,
208    /// `dyn_listen:` - peer-facing listener address.
209    pub dyn_listen: Option<ConfListen>,
210    /// `stats_listen:` - HTTP stats endpoint.
211    pub stats_listen: Option<ConfListen>,
212
213    /// `hash:` - hash function name.
214    pub hash: Option<HashType>,
215    /// `hash_tag:` - two-character delimiter pair.
216    pub hash_tag: Option<String>,
217
218    /// `distribution:` - distribution algorithm. Defaults to
219    /// [`Distribution::Vnode`]. Setting one of the legacy
220    /// `ketama` / `modula` / `random` values is accepted but
221    /// emits a deprecation warning at config-load time and
222    /// collapses to `vnode` at runtime.
223    #[serde(default)]
224    pub distribution: Option<Distribution>,
225    /// `distribution_shadow:` - optional shadow distribution
226    /// computed alongside the live one. When set, the
227    /// dispatcher routes via [`Self::distribution`] but also
228    /// computes the shadow route for every key and bumps a
229    /// counter when the two disagree. Used to validate a
230    /// migration before flipping the live distribution.
231    #[serde(default)]
232    pub distribution_shadow: Option<Distribution>,
233    /// `server_connections:` - deprecated; recorded for warning but ignored.
234    #[serde(default)]
235    pub server_connections: Option<i64>,
236
237    /// `timeout:` - request timeout in milliseconds.
238    pub timeout: Option<i64>,
239    /// `backlog:` - listen backlog.
240    pub backlog: Option<i64>,
241    /// `client_connections:` - max client connections.
242    pub client_connections: Option<i64>,
243    /// `data_store:` - 0 = valkey, 1 = memcache, 2 = dyniak.
244    /// Operators may also write the textual form (`valkey`,
245    /// `memcache`, `dyniak`); the back-compat alias `redis` maps
246    /// to `valkey`. The deserializer normalises every shape to
247    /// the integer code that the rest of the engine consumes.
248    #[serde(default, deserialize_with = "deserialize_data_store")]
249    pub data_store: Option<i64>,
250    /// `noxu_path:` - filesystem directory the in-process Noxu
251    /// DB environment opens at. Required when
252    /// `data_store: dyniak` is selected; ignored otherwise. The
253    /// directory must be writable; an existing environment is
254    /// reused, otherwise one is created.
255    #[serde(default)]
256    pub noxu_path: Option<PathBuf>,
257    /// `search_index_dir:` - filesystem directory the RediSearch
258    /// FT.* index registry snapshots to. When set, the search
259    /// surface persists its index definitions, indexed
260    /// documents, text fields, and suggestion dictionaries to a
261    /// snapshot file under this directory and reloads them on
262    /// restart, so a process kill no longer drops indexes. When
263    /// unset (the default) the registry is purely in-memory:
264    /// indexes are lost on restart and the client must recreate
265    /// them. Only consulted when the binary is built with the
266    /// `search` feature.
267    #[serde(default)]
268    pub search_index_dir: Option<PathBuf>,
269    /// `preconnect:` - eagerly establish connections at startup.
270    pub preconnect: Option<bool>,
271    /// `redis_requirepass:` - optional password sent as `AUTH <pw>`
272    /// on every backend connection right after the TCP handshake.
273    /// Mirrors the Redis server option of the same name. Leave
274    /// unset to disable. Memcache backends are not authenticated
275    /// (`AUTH` is Redis-specific; memcache binary SASL is not
276    /// implemented).
277    #[serde(default)]
278    pub redis_requirepass: Option<String>,
279    /// `auto_eject_hosts:` - automatically eject failing peers.
280    pub auto_eject_hosts: Option<bool>,
281    /// `server_retry_timeout:` - retry interval for ejected servers (ms).
282    pub server_retry_timeout: Option<i64>,
283    /// `server_failure_limit:` - consecutive failures before eject.
284    pub server_failure_limit: Option<i64>,
285
286    /// `servers:` - the (single-element) datastore list.
287    pub servers: Option<Servers>,
288
289    /// `dyn_read_timeout:` - inter-node read timeout (ms).
290    pub dyn_read_timeout: Option<i64>,
291    /// `dyn_write_timeout:` - inter-node write timeout (ms).
292    pub dyn_write_timeout: Option<i64>,
293    /// `dyn_seed_provider:` - seeds backend.
294    pub dyn_seed_provider: Option<String>,
295    /// `dyn_seeds:` - peer dynomite nodes.
296    pub dyn_seeds: Option<Vec<ConfDynSeed>>,
297    /// `dyn_port:` - default peer port.
298    pub dyn_port: Option<i64>,
299    /// `dyn_connections:` - per-peer connection count.
300    pub dyn_connections: Option<i64>,
301    /// `rack:` - this node's rack.
302    pub rack: Option<String>,
303    /// `tokens:` - this node's tokens.
304    pub tokens: Option<TokenList>,
305    /// `gos_interval:` - gossip period (ms).
306    pub gos_interval: Option<i64>,
307    /// `secure_server_option:` - inter-node TLS mode.
308    pub secure_server_option: Option<String>,
309    /// `pem_key_file:` - path to the PEM private key.
310    pub pem_key_file: Option<String>,
311    /// `recon_key_file:` - reconciliation key path.
312    pub recon_key_file: Option<String>,
313    /// `recon_iv_file:` - reconciliation IV path.
314    pub recon_iv_file: Option<String>,
315    /// `recon_interval_seconds:` - period (in seconds) of the
316    /// background entropy reconciliation cycle.
317    ///
318    /// Ignored when [`Self::recon_key_file`] is unset or when the
319    /// configured key file cannot be opened at startup. When the
320    /// entropy task is enabled the default cadence is 300 seconds
321    /// (five minutes); operators can override it via this YAML
322    /// directive.
323    #[serde(default)]
324    pub recon_interval_seconds: Option<u64>,
325    /// `datacenter:` - this node's datacenter.
326    pub datacenter: Option<String>,
327    /// `env:` - cloud environment marker.
328    pub env: Option<String>,
329    /// `conn_msg_rate:` - per-connection message rate cap.
330    pub conn_msg_rate: Option<u32>,
331    /// `read_consistency:` - quorum policy for reads.
332    pub read_consistency: Option<String>,
333    /// `write_consistency:` - quorum policy for writes.
334    pub write_consistency: Option<String>,
335    /// `stats_interval:` - stats aggregation period (ms).
336    pub stats_interval: Option<i64>,
337    /// `enable_gossip:` - enable / disable gossip thread.
338    pub enable_gossip: Option<bool>,
339    /// `peer_tls_cert:` - PEM certificate path for the dnode
340    /// listener and outbound dnode connections. When both this
341    /// field and [`Self::peer_tls_key`] are set the peer plane
342    /// runs over TLS; when both are absent the peer plane runs
343    /// in plaintext (the historical behaviour). Setting one
344    /// without the other is rejected at validation time.
345    #[serde(default)]
346    pub peer_tls_cert: Option<PathBuf>,
347    /// `peer_tls_key:` - PEM private-key path matching
348    /// [`Self::peer_tls_cert`].
349    #[serde(default)]
350    pub peer_tls_key: Option<PathBuf>,
351    /// `peer_tls_ca:` - optional PEM CA bundle. When set, the
352    /// dnode listener requires every inbound peer to present a
353    /// certificate signed by a CA from this bundle (mutual TLS).
354    /// When unset, the listener still terminates TLS but does
355    /// not request a client certificate. The outbound side uses
356    /// this bundle as its trust anchor; when unset, the bundled
357    /// `webpki_roots` Mozilla bundle is used.
358    #[serde(default)]
359    pub peer_tls_ca: Option<PathBuf>,
360    /// `peer_tls_profiles:` - per-DC TLS material lookup. Each
361    /// entry is keyed by the target peer's datacenter name; the
362    /// value is a [`ConfTlsProfile`] giving the cert / key / CA
363    /// triple to use when negotiating peer-plane TLS to or from a
364    /// peer in that DC. When the inbound listener accepts a
365    /// connection it picks the cert by SNI hostname
366    /// (`dc-<dc-name>.dynomite.local`); the outbound peer
367    /// supervisor dials with the same SNI hostname so the remote
368    /// listener can route the handshake.
369    ///
370    /// When this map is empty (the default), the legacy
371    /// `peer_tls_cert` / `peer_tls_key` / `peer_tls_ca` triple is
372    /// the only profile in use, applied to every peer regardless
373    /// of DC. When the map is non-empty, each entry takes
374    /// precedence over the legacy fields for matching DCs; the
375    /// legacy fields become the implicit "default" profile used
376    /// for any DC without an explicit entry. When neither the
377    /// map nor the legacy fields are set, the peer plane is
378    /// plaintext.
379    #[serde(default)]
380    pub peer_tls_profiles: BTreeMap<String, ConfTlsProfile>,
381    /// `mbuf_size:` - mbuf chunk size in bytes.
382    pub mbuf_size: Option<i64>,
383    /// `max_msgs:` - allocated message buffer size.
384    pub max_msgs: Option<i64>,
385    /// `datastore_connections:` - count of connections to the datastore.
386    pub datastore_connections: Option<u8>,
387    /// `local_peer_connections:` - count of connections to local-DC peers.
388    pub local_peer_connections: Option<u8>,
389    /// `remote_peer_connections:` - count of connections to remote peers.
390    pub remote_peer_connections: Option<u8>,
391    /// `read_repairs_enabled:` - enable read-repair on quorum mismatch.
392    pub read_repairs_enabled: Option<bool>,
393    /// `enable_hinted_handoff:` - when true, writes whose target
394    /// peer is in [`crate::cluster::peer::PeerState::Down`] (or
395    /// whose outbound channel is closed / full) are stored in a
396    /// node-local hint queue and counted toward the consistency
397    /// threshold; a background drainer ships the hints to the
398    /// peer once it returns to
399    /// [`crate::cluster::peer::PeerState::Normal`]. When false
400    /// (the default) the dispatcher behaviour is unchanged: a
401    /// Down or unreachable target is silently skipped and the
402    /// request fails with `DynomiteNoQuorumAchieved` if the
403    /// remaining targets cannot satisfy the consistency level.
404    #[serde(default)]
405    pub enable_hinted_handoff: Option<bool>,
406    /// `hint_ttl_seconds:` - per-hint expiry. Hints older than
407    /// this many seconds are dropped during periodic sweeps to
408    /// bound the in-memory store. Defaults to 86400 (24 hours).
409    /// Ignored when `enable_hinted_handoff` is false.
410    #[serde(default)]
411    pub hint_ttl_seconds: Option<u64>,
412    /// `hint_store_max_bytes:` - upper bound on the in-memory
413    /// hint store. Once the store reaches this many bytes,
414    /// further enqueues fail with
415    /// [`crate::cluster::hints::HintStoreError::OverCapacity`]
416    /// and the dispatcher falls back to its non-handoff error
417    /// path. Defaults to 64 MiB. Ignored when
418    /// `enable_hinted_handoff` is false.
419    #[serde(default)]
420    pub hint_store_max_bytes: Option<u64>,
421    /// `hint_drain_interval_ms:` - period of the background hint
422    /// drainer sweep. Defaults to 30000 ms (30 seconds). Ignored
423    /// when `enable_hinted_handoff` is false.
424    #[serde(default)]
425    pub hint_drain_interval_ms: Option<u64>,
426    /// `hint_dir:` - filesystem directory for the durable
427    /// hinted-handoff backend. When set (and
428    /// `enable_hinted_handoff` is true), the hint store keeps one
429    /// append-only segment file per peer under this directory and
430    /// replays them at startup, so hints queued for a temporarily
431    /// down peer survive a coordinator restart. When unset (the
432    /// default) the hint store is RAM-only and queued hints are
433    /// lost on restart. Ignored when `enable_hinted_handoff` is
434    /// false.
435    #[serde(default)]
436    pub hint_dir: Option<PathBuf>,
437    /// `log_format:` - selectable shape for tracing output.
438    ///
439    /// Accepted values are `default`, `rfc5424`, `rfc3164`, `json`,
440    /// and `ndjson` (alias of `json`). When unset, the historical
441    /// default text format is used. Parsing is performed at
442    /// log-installation time by [`crate::core::log::LogFormat::parse`];
443    /// invalid values fail the `dynomited --test-conf` gate.
444    pub log_format: Option<String>,
445
446    /// `observability:` - opt-in observability knobs (distributed
447    /// tracing OTLP exporter and an OTLP log-appender bridge).
448    /// Absent / null disables every observability surface,
449    /// preserving the silent default.
450    /// See [`ObservabilityConfig`].
451    #[serde(default)]
452    pub observability: Option<ObservabilityConfig>,
453
454    /// `bucket_types:` - per-bucket routing-property bundles.
455    ///
456    /// Each entry is a [`ConfBucketType`] keyed by name. The
457    /// dispatcher extracts the bucket name from the request key
458    /// (the prefix before the first `/`; see
459    /// [`crate::proto::redis::bucket_name`]) and, when a matching
460    /// entry exists, swaps in that type's `read_consistency`,
461    /// `write_consistency`, and `n_val` for the lifetime of that
462    /// request. Pool-level fields stay the fallback for keys
463    /// without a slash, for keys whose bucket prefix is unknown,
464    /// and for any field the bucket-type stanza leaves at its
465    /// default. Empty list disables the feature; entries must have
466    /// unique names.
467    #[serde(default)]
468    pub bucket_types: Vec<ConfBucketType>,
469
470    /// `default_bucket_type:` - name of the bucket type to apply
471    /// when the request key has no slash (or has an empty prefix).
472    /// Must reference an entry of `bucket_types` when set; unset
473    /// (the default) falls all the way back to pool-level
474    /// consistency.
475    #[serde(default)]
476    pub default_bucket_type: Option<String>,
477
478    /// `riak:` - optional Riak-mode listener / AAE configuration.
479    ///
480    /// Consumed only when `dynomited` is built with the
481    /// `--features riak` Cargo feature: the binary then
482    /// instantiates a Protocol Buffers Client (PBC) listener,
483    /// an HTTP gateway, and (optionally) the active anti-entropy
484    /// scheduler against the supplied addresses. When the field
485    /// is absent (or every inner option is unset), the binary
486    /// behaves identically to a Redis / Memcache deployment.
487    /// The block is parsed unconditionally so YAML files
488    /// authored against the Riak-enabled binary still validate
489    /// under the default build.
490    #[serde(default)]
491    pub riak: Option<ConfRiak>,
492
493    /// `transport:` - selects the network stack the proxy
494    /// listener binds. `tcp` is the historical default and is
495    /// the only option a build without the `quic` Cargo
496    /// feature satisfies. `quic` requires the engine's `quic`
497    /// feature and a server cert / key pair supplied via
498    /// [`Self::quic_cert_file`] and [`Self::quic_key_file`];
499    /// the listener binds a UDP socket and serves the
500    /// configured datastore protocol over a single QUIC
501    /// bidirectional stream per accepted connection.
502    ///
503    /// When unset (the default), the engine selects
504    /// [`Transport::Tcp`].
505    #[serde(default)]
506    pub transport: Option<Transport>,
507
508    /// `quic_cert_file:` - PEM certificate chain path used by
509    /// the QUIC listener when `transport: quic` is selected.
510    /// Required when [`Self::transport`] resolves to
511    /// [`Transport::Quic`]; ignored otherwise.
512    #[serde(default)]
513    pub quic_cert_file: Option<PathBuf>,
514
515    /// `quic_key_file:` - PEM private-key path matching
516    /// [`Self::quic_cert_file`]. Required when
517    /// [`Self::transport`] resolves to [`Transport::Quic`];
518    /// ignored otherwise.
519    #[serde(default)]
520    pub quic_key_file: Option<PathBuf>,
521}
522
523/// Optional Riak-mode listener / AAE knobs.
524///
525/// Every field is optional. The PBC and HTTP listeners are
526/// independent: setting one without the other is supported.
527/// When `aae_enabled` is `true` the active anti-entropy
528/// scheduler is spawned; the cadence knobs default to the
529/// values shipped by `dyniak::aae::config`.
530///
531/// # Examples
532///
533/// ```
534/// use dynomite::conf::ConfRiak;
535/// let r = ConfRiak {
536///     pbc_listen: Some("127.0.0.1:8087".into()),
537///     http_listen: Some("127.0.0.1:8098".into()),
538///     quic_listen: None,
539///     aae_enabled: Some(false),
540///     aae_full_sweep_interval_seconds: None,
541///     aae_segment_interval_seconds: None,
542///     tls_cert: None,
543///     tls_key: None,
544///     tls_ca: None,
545///     wasm_modules: None,
546/// };
547/// assert!(r.validate().is_ok());
548/// ```
549#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
550#[serde(deny_unknown_fields, default)]
551pub struct ConfRiak {
552    /// Address the Riak Protocol Buffers Client listener binds
553    /// to (`host:port`). When unset, the PBC listener is not
554    /// started.
555    pub pbc_listen: Option<String>,
556    /// Address the Riak HTTP gateway listener binds to
557    /// (`host:port`). When unset, the HTTP gateway is not
558    /// started.
559    pub http_listen: Option<String>,
560    /// Address the Riak Protocol Buffers Client listener binds
561    /// to over QUIC (`host:port`, a UDP socket). When unset,
562    /// the QUIC PBC listener is not started. The PBC framing is
563    /// identical to the TCP listener; only the byte transport
564    /// differs (a single QUIC bidirectional stream per accepted
565    /// connection).
566    ///
567    /// QUIC mandates TLS, so a QUIC listener reuses the
568    /// [`Self::tls_cert`] / [`Self::tls_key`] pair: setting
569    /// `quic_listen` without both is rejected at validation
570    /// time. Serving QUIC also requires `dynomited` to be built
571    /// with the `quic` Cargo feature; when the knob is set but
572    /// the feature is absent the binary fails fast at startup
573    /// with a clean configuration error rather than silently
574    /// ignoring the directive.
575    pub quic_listen: Option<String>,
576    /// When `true`, the Riak active anti-entropy scheduler is
577    /// spawned alongside the listeners. Default: `false`.
578    pub aae_enabled: Option<bool>,
579    /// Override for the AAE full-sweep cadence, in seconds.
580    /// When unset, `dyniak::aae::config::DEFAULT_FULL_SWEEP_SECONDS`
581    /// (24h) is used.
582    pub aae_full_sweep_interval_seconds: Option<u64>,
583    /// Override for the AAE per-segment exchange cadence, in
584    /// seconds. When unset, `dyniak::aae::config::DEFAULT_SEGMENT_SECONDS`
585    /// (60s) is used.
586    pub aae_segment_interval_seconds: Option<u64>,
587    /// `tls_cert:` - PEM certificate path for the Riak PBC and
588    /// HTTP listeners. When both `tls_cert` and `tls_key` are
589    /// set, both Riak listeners terminate TLS; when both are
590    /// absent, both listeners run in plaintext (the historical
591    /// behaviour). Setting one without the other is rejected at
592    /// validation time.
593    #[serde(default)]
594    pub tls_cert: Option<PathBuf>,
595    /// `tls_key:` - PEM private-key path matching
596    /// [`Self::tls_cert`].
597    #[serde(default)]
598    pub tls_key: Option<PathBuf>,
599    /// `tls_ca:` - optional PEM CA bundle for mutual TLS on the
600    /// Riak listeners. When set, every inbound client must
601    /// present a certificate signed by a CA from this bundle.
602    /// When unset, the listeners terminate TLS without
603    /// requesting a client certificate.
604    #[serde(default)]
605    pub tls_ca: Option<PathBuf>,
606    /// `wasm_modules:` - optional list of Wasm modules to
607    /// register with the MapReduce executor at startup. Each
608    /// entry pairs a logical `id` with the on-disk `path` of a
609    /// Wasm binary (`.wasm`) or WAT text (`.wat`) file. When
610    /// `dynomited` is built with the `wasm` Cargo feature it
611    /// loads every entry through the dyniak MapReduce Wasm
612    /// loader (`dyniak::mapreduce::wasm::load_modules_from_config`)
613    /// and exposes the resulting store on the executor; without
614    /// the feature the field is parsed and validated but the
615    /// loader is never called (the runtime returns the typed
616    /// `WasmNotImplemented` error if a `Phase::WasmModule` is
617    /// submitted).
618    ///
619    /// Validation: every `id` must be unique and every `path`
620    /// must point at an existing file at validation time.
621    #[serde(default)]
622    pub wasm_modules: Option<Vec<ConfRiakWasmModule>>,
623}
624
625/// One Wasm module entry inside a [`ConfRiak::wasm_modules`]
626/// list.
627///
628/// # Examples
629///
630/// ```
631/// use std::path::PathBuf;
632/// use dynomite::conf::ConfRiakWasmModule;
633/// let m = ConfRiakWasmModule {
634///     id: "identity".into(),
635///     path: PathBuf::from("/etc/dynomited/wasm/identity.wasm"),
636/// };
637/// assert_eq!(m.id, "identity");
638/// ```
639#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
640#[serde(deny_unknown_fields)]
641pub struct ConfRiakWasmModule {
642    /// Logical module identifier referenced from a
643    /// `Phase::WasmModule { module_id }` MapReduce phase.
644    pub id: String,
645    /// Filesystem path to the Wasm binary (`.wasm`) or WAT
646    /// text (`.wat`) file. Read once at startup.
647    pub path: PathBuf,
648}
649
650/// One per-DC TLS profile inside [`ConfPool::peer_tls_profiles`].
651///
652/// Each profile names a PEM cert / private-key pair and an
653/// optional CA bundle. The map's key is the datacenter name
654/// (matching the value an operator sets in `dyn_seeds:` for
655/// peers in that DC); when a peer's DC has no entry, the
656/// connection falls back to the legacy `peer_tls_*` fields
657/// (treated as the implicit "default" profile). When neither a
658/// per-DC entry nor the default fields are set, the connection
659/// is plaintext.
660///
661/// # Examples
662///
663/// ```
664/// use std::path::PathBuf;
665/// use dynomite::conf::ConfTlsProfile;
666/// let p = ConfTlsProfile {
667///     cert: Some(PathBuf::from("/etc/dynomite/dc1.pem")),
668///     key: Some(PathBuf::from("/etc/dynomite/dc1.key")),
669///     ca: Some(PathBuf::from("/etc/dynomite/dc1-ca.pem")),
670/// };
671/// assert!(p.validate("dc1").is_ok());
672/// ```
673#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
674#[serde(deny_unknown_fields, default)]
675pub struct ConfTlsProfile {
676    /// PEM certificate path. Must be set together with [`Self::key`].
677    pub cert: Option<PathBuf>,
678    /// PEM private-key path matching [`Self::cert`].
679    pub key: Option<PathBuf>,
680    /// Optional PEM CA bundle. When set, peer-plane connections
681    /// using this profile pin the bundle as their trust anchor
682    /// (and the listener requires inbound peers to present a
683    /// certificate signed by a CA in the bundle for mTLS). When
684    /// unset, the listener does not request a client certificate
685    /// and the outbound side falls back to the bundled
686    /// `webpki_roots` Mozilla anchors.
687    pub ca: Option<PathBuf>,
688}
689
690impl ConfTlsProfile {
691    /// Validate that the profile is internally consistent.
692    ///
693    /// `cert` and `key` must both be set or both be unset; a
694    /// `ca` requires the cert / key pair. The `dc` argument is
695    /// the map key the profile lives under and is included in
696    /// any error message so an operator can identify the
697    /// offending entry.
698    ///
699    /// # Errors
700    /// Returns [`ConfError::BadServer`] when the cert / key
701    /// pair is mismatched, or when `ca` is set without the cert
702    /// / key pair.
703    ///
704    /// # Examples
705    ///
706    /// ```
707    /// use std::path::PathBuf;
708    /// use dynomite::conf::ConfTlsProfile;
709    /// let p = ConfTlsProfile {
710    ///     cert: Some(PathBuf::from("/etc/x.pem")),
711    ///     key: None,
712    ///     ca: None,
713    /// };
714    /// assert!(p.validate("dc1").is_err());
715    /// ```
716    pub fn validate(&self, dc: &str) -> Result<(), ConfError> {
717        match (self.cert.as_deref(), self.key.as_deref()) {
718            (Some(_), Some(_)) | (None, None) => {}
719            (Some(c), None) => {
720                return Err(ConfError::BadServer {
721                    field: "peer_tls_profiles.cert",
722                    value: c.display().to_string(),
723                    reason: format!(
724                        "peer_tls_profiles[{dc}].cert is set but .key is not; both must be set together"
725                    ),
726                });
727            }
728            (None, Some(k)) => {
729                return Err(ConfError::BadServer {
730                    field: "peer_tls_profiles.key",
731                    value: k.display().to_string(),
732                    reason: format!(
733                        "peer_tls_profiles[{dc}].key is set but .cert is not; both must be set together"
734                    ),
735                });
736            }
737        }
738        if self.ca.is_some() && self.cert.is_none() {
739            return Err(ConfError::BadServer {
740                field: "peer_tls_profiles.ca",
741                value: self
742                    .ca
743                    .as_ref()
744                    .map_or_else(String::new, |p| p.display().to_string()),
745                reason: format!(
746                    "peer_tls_profiles[{dc}].ca requires .cert and .key to also be set"
747                ),
748            });
749        }
750        Ok(())
751    }
752}
753
754impl ConfRiak {
755    /// Validate the cross-field invariants of the Riak block.
756    ///
757    /// # Errors
758    /// Returns a [`ConfError::BadServer`] when an address fails
759    /// to parse as a `host:port` socket address, when an AAE
760    /// cadence is zero, or when `aae_segment_interval_seconds`
761    /// exceeds `aae_full_sweep_interval_seconds`.
762    ///
763    /// # Examples
764    ///
765    /// ```
766    /// use dynomite::conf::ConfRiak;
767    /// let r = ConfRiak {
768    ///     pbc_listen: Some("not-a-socket-addr".into()),
769    ///     ..ConfRiak::default()
770    /// };
771    /// assert!(r.validate().is_err());
772    /// ```
773    pub fn validate(&self) -> Result<(), ConfError> {
774        if let Some(addr) = self.pbc_listen.as_deref() {
775            validate_riak_addr("pbc_listen", addr)?;
776        }
777        if let Some(addr) = self.http_listen.as_deref() {
778            validate_riak_addr("http_listen", addr)?;
779        }
780        if let Some(addr) = self.quic_listen.as_deref() {
781            validate_riak_addr("quic_listen", addr)?;
782            // QUIC mandates TLS; the listener reuses the
783            // `tls_cert` / `tls_key` pair, so both must be set
784            // when a QUIC address is configured.
785            if self.tls_cert.is_none() || self.tls_key.is_none() {
786                return Err(ConfError::BadServer {
787                    field: "quic_listen",
788                    value: addr.to_string(),
789                    reason: "quic_listen requires tls_cert and tls_key to also be set".into(),
790                });
791            }
792        }
793        if let Some(n) = self.aae_full_sweep_interval_seconds {
794            if n == 0 {
795                return Err(ConfError::BadServer {
796                    field: "aae_full_sweep_interval_seconds",
797                    value: n.to_string(),
798                    reason: "must be > 0".into(),
799                });
800            }
801        }
802        if let Some(n) = self.aae_segment_interval_seconds {
803            if n == 0 {
804                return Err(ConfError::BadServer {
805                    field: "aae_segment_interval_seconds",
806                    value: n.to_string(),
807                    reason: "must be > 0".into(),
808                });
809            }
810        }
811        if let (Some(seg), Some(full)) = (
812            self.aae_segment_interval_seconds,
813            self.aae_full_sweep_interval_seconds,
814        ) {
815            if seg > full {
816                return Err(ConfError::BadServer {
817                    field: "aae_segment_interval_seconds",
818                    value: seg.to_string(),
819                    reason: format!("must be <= aae_full_sweep_interval_seconds ({full})"),
820                });
821            }
822        }
823        validate_tls_pair(
824            "tls_cert",
825            "tls_key",
826            self.tls_cert.as_deref(),
827            self.tls_key.as_deref(),
828        )?;
829        if self.tls_ca.is_some() && self.tls_cert.is_none() {
830            return Err(ConfError::BadServer {
831                field: "tls_ca",
832                value: self
833                    .tls_ca
834                    .as_ref()
835                    .map_or_else(String::new, |p| p.display().to_string()),
836                reason: "requires tls_cert and tls_key to also be set".into(),
837            });
838        }
839        if let Some(modules) = self.wasm_modules.as_deref() {
840            let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
841            for m in modules {
842                if m.id.is_empty() {
843                    return Err(ConfError::BadServer {
844                        field: "wasm_modules.id",
845                        value: String::new(),
846                        reason: "wasm module id must not be empty".into(),
847                    });
848                }
849                if !seen.insert(m.id.as_str()) {
850                    return Err(ConfError::BadServer {
851                        field: "wasm_modules.id",
852                        value: m.id.clone(),
853                        reason: "wasm module ids must be unique".into(),
854                    });
855                }
856                if !m.path.is_file() {
857                    return Err(ConfError::BadServer {
858                        field: "wasm_modules.path",
859                        value: m.path.display().to_string(),
860                        reason: format!("wasm module file not found for id '{}'", m.id),
861                    });
862                }
863            }
864        }
865        Ok(())
866    }
867}
868
869/// Cross-check a `(cert, key)` TLS pair: both must be `Some` or
870/// both must be `None`. The `cert_field` and `key_field` static
871/// strings name the YAML keys for the error message.
872fn validate_tls_pair(
873    cert_field: &'static str,
874    key_field: &'static str,
875    cert: Option<&std::path::Path>,
876    key: Option<&std::path::Path>,
877) -> Result<(), ConfError> {
878    match (cert, key) {
879        (Some(_), Some(_)) | (None, None) => Ok(()),
880        (Some(c), None) => Err(ConfError::BadServer {
881            field: cert_field,
882            value: c.display().to_string(),
883            reason: format!(
884                "{cert_field} is set but {key_field} is not; both must be set together"
885            ),
886        }),
887        (None, Some(k)) => Err(ConfError::BadServer {
888            field: key_field,
889            value: k.display().to_string(),
890            reason: format!(
891                "{key_field} is set but {cert_field} is not; both must be set together"
892            ),
893        }),
894    }
895}
896
897fn validate_riak_addr(field: &'static str, value: &str) -> Result<(), ConfError> {
898    use std::net::ToSocketAddrs;
899    if value.is_empty() {
900        return Err(ConfError::BadServer {
901            field,
902            value: value.to_string(),
903            reason: "riak listen address must not be empty".into(),
904        });
905    }
906    // Accept anything that resolves; mirrors how the rest of
907    // the engine validates `listen:` strings (parse first,
908    // resolve as a fallback).
909    if value.parse::<std::net::SocketAddr>().is_ok() {
910        return Ok(());
911    }
912    match value.to_socket_addrs() {
913        Ok(mut iter) => {
914            if iter.next().is_some() {
915                Ok(())
916            } else {
917                Err(ConfError::BadServer {
918                    field,
919                    value: value.to_string(),
920                    reason: "resolved to no addresses".into(),
921                })
922            }
923        }
924        Err(e) => Err(ConfError::BadServer {
925            field,
926            value: value.to_string(),
927            reason: format!("could not resolve: {e}"),
928        }),
929    }
930}
931
932/// Routing-property bundle attached to a key bucket.
933///
934/// Bucket types let operators give different key classes
935/// different SLAs without running multiple pools. Cache-style
936/// keys can pin to `DC_ONE` while transactional keys sit on
937/// `DC_EACH_SAFE_QUORUM`; the same dynomited binary serves both.
938///
939/// `n_val` caps the replica fan-out for the lifetime of one
940/// request: when the topology offers more replicas than `n_val`,
941/// the dispatcher takes the first `n_val` (the existing rack /
942/// DC ordering puts preferred replicas first). A value of `0`
943/// means "no cap" and is treated identically to omitting the
944/// field.
945///
946/// # Examples
947///
948/// ```
949/// use dynomite::conf::{ConfBucketType, ConsistencyLevel};
950/// let bt = ConfBucketType {
951///     name: "sessions".into(),
952///     read_consistency: "DC_QUORUM".into(),
953///     write_consistency: "DC_EACH_SAFE_QUORUM".into(),
954///     n_val: 3,
955/// };
956/// assert_eq!(bt.name, "sessions");
957/// assert_eq!(
958///     ConsistencyLevel::parse("read_consistency", &bt.read_consistency).unwrap(),
959///     ConsistencyLevel::DcQuorum,
960/// );
961/// assert_eq!(bt.n_val, 3);
962/// ```
963#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
964#[serde(deny_unknown_fields)]
965pub struct ConfBucketType {
966    /// Bucket name. Compared verbatim against the bytes returned
967    /// by [`crate::proto::redis::bucket_name`]; no normalisation
968    /// is performed. Names must be unique within a pool and must
969    /// not be empty.
970    pub name: String,
971    /// Read-side consistency level for keys in this bucket.
972    /// Stored as a string so the YAML round-trip is
973    /// human-readable; parsed via
974    /// [`ConsistencyLevel::parse`](crate::conf::ConsistencyLevel::parse)
975    /// during validation.
976    pub read_consistency: String,
977    /// Write-side consistency level for keys in this bucket.
978    /// See [`ConfBucketType::read_consistency`].
979    pub write_consistency: String,
980    /// Replication-factor cap. The dispatcher trims its replica
981    /// fan-out to at most this many targets. `0` means "no cap";
982    /// any positive value caps the fan-out to the leading
983    /// `n_val` peers (rack-local first).
984    #[serde(default)]
985    pub n_val: u8,
986}
987
988impl ConfBucketType {
989    /// Parse [`Self::read_consistency`] into the typed enum.
990    ///
991    /// # Examples
992    ///
993    /// ```
994    /// use dynomite::conf::{ConfBucketType, ConsistencyLevel};
995    /// let bt = ConfBucketType {
996    ///     name: "s".into(),
997    ///     read_consistency: "DC_QUORUM".into(),
998    ///     write_consistency: "DC_ONE".into(),
999    ///     n_val: 0,
1000    /// };
1001    /// assert_eq!(bt.read_level().unwrap(), ConsistencyLevel::DcQuorum);
1002    /// ```
1003    pub fn read_level(&self) -> Result<ConsistencyLevel, ConfError> {
1004        ConsistencyLevel::parse("read_consistency", &self.read_consistency)
1005    }
1006
1007    /// Parse [`Self::write_consistency`] into the typed enum.
1008    ///
1009    /// # Examples
1010    ///
1011    /// ```
1012    /// use dynomite::conf::{ConfBucketType, ConsistencyLevel};
1013    /// let bt = ConfBucketType {
1014    ///     name: "s".into(),
1015    ///     read_consistency: "DC_ONE".into(),
1016    ///     write_consistency: "DC_SAFE_QUORUM".into(),
1017    ///     n_val: 0,
1018    /// };
1019    /// assert_eq!(bt.write_level().unwrap(), ConsistencyLevel::DcSafeQuorum);
1020    /// ```
1021    pub fn write_level(&self) -> Result<ConsistencyLevel, ConfError> {
1022        ConsistencyLevel::parse("write_consistency", &self.write_consistency)
1023    }
1024}
1025
1026/// Opt-in observability configuration.
1027///
1028/// Absent or null disables every observability surface. Covers both
1029/// distributed tracing and an OTLP log-appender bridge that share
1030/// the same configuration fields.
1031///
1032/// # Examples
1033///
1034/// ```
1035/// use dynomite::conf::ObservabilityConfig;
1036/// let cfg = ObservabilityConfig {
1037///     otlp_traces_endpoint: Some("http://collector:4317".into()),
1038///     otlp_logs_endpoint: None,
1039///     service_name: Some("dynomited".into()),
1040///     traces_sampling: Some(0.1),
1041/// };
1042/// assert!(cfg.otlp_traces_endpoint.is_some());
1043/// ```
1044#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1045#[serde(deny_unknown_fields, default)]
1046pub struct ObservabilityConfig {
1047    /// OTLP gRPC endpoint for distributed traces (e.g.
1048    /// `http://localhost:4317`). When `None` the binary skips
1049    /// the OTel SDK install entirely; tracing keeps using the
1050    /// configured `tracing-subscriber` log layer only.
1051    pub otlp_traces_endpoint: Option<String>,
1052    /// OTLP gRPC endpoint for log records. When set the binary
1053    /// installs an `opentelemetry-appender-tracing` bridge
1054    /// alongside the local log writer, forwarding log records to
1055    /// the collector. When `None` the binary keeps the local log
1056    /// writer only.
1057    pub otlp_logs_endpoint: Option<String>,
1058    /// Service name attached to every emitted span / log record.
1059    /// Defaults to `"dynomited"` when unset.
1060    pub service_name: Option<String>,
1061    /// Trace sampling ratio in `[0.0, 1.0]`. `1.0` records every
1062    /// trace, `0.0` records none. Defaults to `1.0` when unset.
1063    pub traces_sampling: Option<f64>,
1064}
1065
1066impl ConfPool {
1067    /// Resolve the configured [`Distribution`] for the engine.
1068    ///
1069    /// Folds the legacy `ketama` / `modula` / `random` aliases
1070    /// down to [`Distribution::Vnode`] (the only algorithm those
1071    /// names resolve to). Emits a
1072    /// `tracing::warn!` for the legacy aliases the first time
1073    /// the field is read so the operator notices the
1074    /// deprecation.
1075    ///
1076    /// # Examples
1077    ///
1078    /// ```
1079    /// use dynomite::conf::{ConfPool, Distribution};
1080    /// let p = ConfPool {
1081    ///     distribution: Some(Distribution::RandomSlicing),
1082    ///     ..ConfPool::default()
1083    /// };
1084    /// assert_eq!(p.resolved_distribution(), Distribution::RandomSlicing);
1085    /// ```
1086    #[must_use]
1087    pub fn resolved_distribution(&self) -> Distribution {
1088        match self.distribution {
1089            None | Some(Distribution::Vnode) => Distribution::Vnode,
1090            Some(Distribution::RandomSlicing) => Distribution::RandomSlicing,
1091            Some(other) => {
1092                tracing::warn!(
1093                    target: "dynomite::conf",
1094                    distribution = other.as_str(),
1095                    "distribution mode '{}' is a legacy alias and resolves to 'vnode'; \
1096                     update the YAML to either 'vnode' or 'random_slicing'",
1097                    other
1098                );
1099                Distribution::Vnode
1100            }
1101        }
1102    }
1103}
1104
1105impl ConfPool {
1106    /// Apply defaults to any field still left `None` after parsing.
1107    ///
1108    /// # Examples
1109    ///
1110    /// ```
1111    /// use dynomite::conf::ConfPool;
1112    /// let mut p = ConfPool::default();
1113    /// p.apply_defaults();
1114    /// assert_eq!(p.timeout, Some(5_000));
1115    /// assert_eq!(p.rack.as_deref(), Some("localrack"));
1116    /// ```
1117    pub fn apply_defaults(&mut self) {
1118        if self.dyn_seed_provider.is_none() {
1119            self.dyn_seed_provider = Some(defaults::SEED_PROVIDER.to_string());
1120        }
1121        if self.hash.is_none() {
1122            self.hash = Some(HashType::Murmur);
1123        }
1124        if self.timeout.is_none() {
1125            self.timeout = Some(defaults::TIMEOUT_MS);
1126        }
1127        if self.backlog.is_none() {
1128            self.backlog = Some(defaults::LISTEN_BACKLOG);
1129        }
1130        // client_connections is unconditionally reset to its
1131        // default here regardless of any configured value.
1132        self.client_connections = Some(defaults::CLIENT_CONNECTIONS);
1133        if self.data_store.is_none() {
1134            self.data_store = Some(defaults::DATA_STORE);
1135        }
1136        if self.preconnect.is_none() {
1137            self.preconnect = Some(defaults::PRECONNECT);
1138        }
1139        if self.auto_eject_hosts.is_none() {
1140            self.auto_eject_hosts = Some(defaults::AUTO_EJECT_HOSTS);
1141        }
1142        if self.server_retry_timeout.is_none() {
1143            self.server_retry_timeout = Some(defaults::SERVER_RETRY_TIMEOUT_MS);
1144        }
1145        if self.server_failure_limit.is_none() {
1146            self.server_failure_limit = Some(defaults::SERVER_FAILURE_LIMIT);
1147        }
1148        if self.dyn_read_timeout.is_none() {
1149            self.dyn_read_timeout = Some(defaults::DYN_READ_TIMEOUT_MS);
1150        }
1151        if self.dyn_write_timeout.is_none() {
1152            self.dyn_write_timeout = Some(defaults::DYN_WRITE_TIMEOUT_MS);
1153        }
1154        if self.dyn_connections.is_none() {
1155            self.dyn_connections = Some(defaults::DYN_CONNECTIONS);
1156        }
1157        if self.gos_interval.is_none() {
1158            self.gos_interval = Some(defaults::GOS_INTERVAL_MS);
1159        }
1160        if self.conn_msg_rate.is_none() {
1161            self.conn_msg_rate = Some(defaults::CONN_MSG_RATE);
1162        }
1163        if self.rack.is_none() {
1164            self.rack = Some(defaults::RACK.to_string());
1165        }
1166        if self.datacenter.is_none() {
1167            self.datacenter = Some(defaults::DC.to_string());
1168        }
1169        if self.secure_server_option.is_none() {
1170            self.secure_server_option = Some(defaults::SECURE_SERVER_OPTION.to_string());
1171        }
1172        if self.read_consistency.is_none() {
1173            self.read_consistency = Some(defaults::CONSISTENCY.to_string());
1174        }
1175        if self.write_consistency.is_none() {
1176            self.write_consistency = Some(defaults::CONSISTENCY.to_string());
1177        }
1178        if self.stats_interval.is_none() {
1179            self.stats_interval = Some(defaults::STATS_INTERVAL_MS);
1180        }
1181        if self.stats_listen.is_none() {
1182            // Safe: the constant is a hard-coded valid pname.
1183            self.stats_listen = Some(
1184                ConfListen::parse("stats_listen", defaults::STATS_PNAME)
1185                    .expect("invariant: STATS_PNAME constant is valid"),
1186            );
1187        }
1188        if self.env.is_none() {
1189            self.env = Some(defaults::ENV.to_string());
1190        }
1191        if self.pem_key_file.is_none() {
1192            self.pem_key_file = Some(defaults::PEM_KEY_FILE.to_string());
1193        }
1194        if self.recon_key_file.is_none() {
1195            self.recon_key_file = Some(defaults::RECON_KEY_FILE.to_string());
1196        }
1197        if self.recon_iv_file.is_none() {
1198            self.recon_iv_file = Some(defaults::RECON_IV_FILE.to_string());
1199        }
1200        if self.recon_interval_seconds.is_none() {
1201            self.recon_interval_seconds = Some(defaults::RECON_INTERVAL_SECONDS);
1202        }
1203        if self.datastore_connections.is_none() {
1204            self.datastore_connections = Some(defaults::DATASTORE_CONNECTIONS);
1205        }
1206        if self.local_peer_connections.is_none() {
1207            self.local_peer_connections = Some(defaults::LOCAL_PEER_CONNECTIONS);
1208        }
1209        if self.remote_peer_connections.is_none() {
1210            self.remote_peer_connections = Some(defaults::REMOTE_PEER_CONNECTIONS);
1211        }
1212        if self.read_repairs_enabled.is_none() {
1213            self.read_repairs_enabled = Some(false);
1214        }
1215        if self.enable_gossip.is_none() {
1216            self.enable_gossip = Some(false);
1217        }
1218        self.apply_hinted_handoff_defaults();
1219    }
1220
1221    /// Fill in the hinted-handoff knobs and the transport
1222    /// selector. Factored out of [`Self::apply_defaults`] to
1223    /// keep the parent method under the project's per-function
1224    /// line budget while still covering every recently-added
1225    /// key with a default.
1226    fn apply_hinted_handoff_defaults(&mut self) {
1227        if self.enable_hinted_handoff.is_none() {
1228            self.enable_hinted_handoff = Some(defaults::ENABLE_HINTED_HANDOFF);
1229        }
1230        if self.hint_ttl_seconds.is_none() {
1231            self.hint_ttl_seconds = Some(defaults::HINT_TTL_SECONDS);
1232        }
1233        if self.hint_store_max_bytes.is_none() {
1234            self.hint_store_max_bytes = Some(defaults::HINT_STORE_MAX_BYTES);
1235        }
1236        if self.hint_drain_interval_ms.is_none() {
1237            self.hint_drain_interval_ms = Some(defaults::HINT_DRAIN_INTERVAL_MS);
1238        }
1239        if self.transport.is_none() {
1240            self.transport = Some(Transport::default());
1241        }
1242    }
1243
1244    /// Run the full validation pass against the (presumably finalized)
1245    /// pool body.
1246    ///
1247    /// # Examples
1248    ///
1249    /// ```
1250    /// use dynomite::conf::{ConfListen, ConfPool, ConfServer, Servers, TokenList};
1251    /// let mut p = ConfPool {
1252    ///     listen: Some(ConfListen::parse("listen", "127.0.0.1:8102").unwrap()),
1253    ///     servers: Some(Servers::from_vec(vec![ConfServer::parse("127.0.0.1:6379:1").unwrap()])),
1254    ///     tokens: Some(TokenList::parse("0").unwrap()),
1255    ///     ..ConfPool::default()
1256    /// };
1257    /// p.apply_defaults();
1258    /// assert!(p.validate("dyn_o_mite").is_ok());
1259    /// ```
1260    pub fn validate(&self, pool_name: &str) -> Result<(), ConfError> {
1261        if pool_name.is_empty() {
1262            return Err(ConfError::EmptyPoolName);
1263        }
1264
1265        if self.listen.is_none() {
1266            return Err(ConfError::MissingRequired("listen"));
1267        }
1268
1269        self.validate_numeric_ranges()?;
1270        self.validate_mbuf_size()?;
1271        self.validate_max_msgs()?;
1272
1273        if let Some(n) = self.data_store {
1274            let ds = DataStore::from_int(n)?;
1275            if ds == DataStore::Dyniak {
1276                self.validate_dyniak()?;
1277            }
1278        }
1279        if let Some(tag) = &self.hash_tag {
1280            if tag.chars().count() != 2 {
1281                return Err(ConfError::BadHashTag(tag.clone()));
1282            }
1283        }
1284
1285        let secure = if let Some(s) = &self.secure_server_option {
1286            SecureServerOption::parse(s)?
1287        } else {
1288            SecureServerOption::None
1289        };
1290        if let Some(s) = &self.read_consistency {
1291            ConsistencyLevel::parse("read_consistency", s)?;
1292        }
1293        if let Some(s) = &self.write_consistency {
1294            ConsistencyLevel::parse("write_consistency", s)?;
1295        }
1296        if secure != SecureServerOption::None {
1297            match &self.pem_key_file {
1298                Some(s) if !s.is_empty() => {}
1299                _ => return Err(ConfError::MissingRequired("pem_key_file")),
1300            }
1301        }
1302
1303        if let Some(s) = &self.log_format {
1304            crate::core::log::LogFormat::parse(s).map_err(|e| ConfError::BadServer {
1305                field: "log_format",
1306                value: s.clone(),
1307                reason: e.to_string(),
1308            })?;
1309        }
1310
1311        self.validate_bucket_types()?;
1312        self.validate_hinted_handoff()?;
1313        self.validate_peer_tls()?;
1314        self.validate_transport()?;
1315        if let Some(r) = &self.riak {
1316            r.validate()?;
1317        }
1318
1319        match &self.servers {
1320            None => return Err(ConfError::MissingRequired("servers")),
1321            Some(s) if s.is_empty() => return Err(ConfError::MissingRequired("servers")),
1322            Some(s) if s.len() > 1 => {
1323                return Err(ConfError::BadServer {
1324                    field: "servers",
1325                    value: s.len().to_string(),
1326                    reason: "expected exactly one datastore entry".to_string(),
1327                });
1328            }
1329            Some(_) => {}
1330        }
1331
1332        Ok(())
1333    }
1334
1335    fn validate_numeric_ranges(&self) -> Result<(), ConfError> {
1336        check_positive("timeout", self.timeout)?;
1337        check_positive("backlog", self.backlog)?;
1338        check_non_negative("client_connections", self.client_connections)?;
1339        check_positive("server_retry_timeout", self.server_retry_timeout)?;
1340        check_positive("server_failure_limit", self.server_failure_limit)?;
1341        check_positive("dyn_read_timeout", self.dyn_read_timeout)?;
1342        check_positive("dyn_write_timeout", self.dyn_write_timeout)?;
1343        check_positive("gos_interval", self.gos_interval)?;
1344        check_positive("stats_interval", self.stats_interval)?;
1345
1346        if let Some(n) = self.dyn_connections {
1347            if n <= 0 {
1348                return Err(ConfError::OutOfRange {
1349                    field: "dyn_connections",
1350                    value: n,
1351                    reason: "must be a positive non-zero number",
1352                });
1353            }
1354        }
1355        Ok(())
1356    }
1357
1358    fn validate_mbuf_size(&self) -> Result<(), ConfError> {
1359        let Some(n) = self.mbuf_size else {
1360            return Ok(());
1361        };
1362        if n <= 0 {
1363            return Err(ConfError::OutOfRange {
1364                field: "mbuf_size",
1365                value: n,
1366                reason: "must be a positive number",
1367            });
1368        }
1369        if !(defaults::MBUF_MIN_SIZE..=defaults::MBUF_MAX_SIZE).contains(&n) {
1370            return Err(ConfError::OutOfRange {
1371                field: "mbuf_size",
1372                value: n,
1373                reason: "must be between 512 and 512000 bytes",
1374            });
1375        }
1376        if n % 16 != 0 {
1377            return Err(ConfError::OutOfRange {
1378                field: "mbuf_size",
1379                value: n,
1380                reason: "must be a multiple of 16",
1381            });
1382        }
1383        Ok(())
1384    }
1385
1386    fn validate_max_msgs(&self) -> Result<(), ConfError> {
1387        let Some(n) = self.max_msgs else {
1388            return Ok(());
1389        };
1390        if n <= 0 {
1391            return Err(ConfError::OutOfRange {
1392                field: "max_msgs",
1393                value: n,
1394                reason: "requires a non-zero number",
1395            });
1396        }
1397        if !(defaults::ALLOC_MSGS_MIN..=defaults::ALLOC_MSGS_MAX).contains(&n) {
1398            return Err(ConfError::OutOfRange {
1399                field: "max_msgs",
1400                value: n,
1401                reason: "must be between 100000 and 1000000 messages",
1402            });
1403        }
1404        Ok(())
1405    }
1406
1407    fn validate_bucket_types(&self) -> Result<(), ConfError> {
1408        use std::collections::BTreeSet;
1409        let mut seen: BTreeSet<&str> = BTreeSet::new();
1410        for bt in &self.bucket_types {
1411            if bt.name.is_empty() {
1412                return Err(ConfError::BadServer {
1413                    field: "bucket_types",
1414                    value: String::new(),
1415                    reason: "bucket-type name must not be empty".to_string(),
1416                });
1417            }
1418            if !seen.insert(bt.name.as_str()) {
1419                return Err(ConfError::BadServer {
1420                    field: "bucket_types",
1421                    value: bt.name.clone(),
1422                    reason: "duplicate bucket-type name".to_string(),
1423                });
1424            }
1425            ConsistencyLevel::parse("read_consistency", &bt.read_consistency)?;
1426            ConsistencyLevel::parse("write_consistency", &bt.write_consistency)?;
1427        }
1428        if let Some(name) = &self.default_bucket_type {
1429            if !self.bucket_types.iter().any(|bt| &bt.name == name) {
1430                return Err(ConfError::BadServer {
1431                    field: "default_bucket_type",
1432                    value: name.clone(),
1433                    reason: "references an undefined bucket-type name".to_string(),
1434                });
1435            }
1436        }
1437        Ok(())
1438    }
1439
1440    /// Validate the cross-field invariants of the dyniak
1441    /// datastore selection.
1442    ///
1443    /// Selecting `data_store: dyniak` is permitted only when the
1444    /// binary was built with `--features riak`; without it,
1445    /// `dynomited` cannot construct a `NoxuDatastore` because
1446    /// the `dyniak` crate (which owns the type) is not
1447    /// linked. The check is gated on a `cfg!(feature = ...)`
1448    /// expression that the parent crate threads through via
1449    /// the [`crate::conf::set_dyniak_supported`] toggle: the
1450    /// engine ships with the toggle off, the `dynomited` binary
1451    /// turns it on under `--features riak`. The toggle is
1452    /// global because `data_store: dyniak` is a build-time
1453    /// configuration constraint, not a per-pool one.
1454    ///
1455    /// `noxu_path:` must be set and non-empty.
1456    fn validate_dyniak(&self) -> Result<(), ConfError> {
1457        if !crate::conf::is_dyniak_supported() {
1458            return Err(ConfError::BadDyniakConfig(
1459                "dyniak data_store requires dynomited built with --features riak",
1460            ));
1461        }
1462        match self.noxu_path.as_deref() {
1463            Some(p) if !p.as_os_str().is_empty() => Ok(()),
1464            _ => Err(ConfError::BadDyniakConfig(
1465                "data_store: dyniak requires a non-empty 'noxu_path:' directive",
1466            )),
1467        }
1468    }
1469
1470    fn validate_hinted_handoff(&self) -> Result<(), ConfError> {
1471        if self.enable_hinted_handoff != Some(true) {
1472            return Ok(());
1473        }
1474        if let Some(ttl) = self.hint_ttl_seconds {
1475            if ttl == 0 {
1476                return Err(ConfError::BadServer {
1477                    field: "hint_ttl_seconds",
1478                    value: ttl.to_string(),
1479                    reason: "must be a positive number when enable_hinted_handoff is true"
1480                        .to_string(),
1481                });
1482            }
1483        }
1484        if let Some(cap) = self.hint_store_max_bytes {
1485            if cap == 0 {
1486                return Err(ConfError::BadServer {
1487                    field: "hint_store_max_bytes",
1488                    value: cap.to_string(),
1489                    reason: "must be a positive number when enable_hinted_handoff is true"
1490                        .to_string(),
1491                });
1492            }
1493        }
1494        if let Some(period) = self.hint_drain_interval_ms {
1495            if period == 0 {
1496                return Err(ConfError::BadServer {
1497                    field: "hint_drain_interval_ms",
1498                    value: period.to_string(),
1499                    reason: "must be a positive number when enable_hinted_handoff is true"
1500                        .to_string(),
1501                });
1502            }
1503        }
1504        Ok(())
1505    }
1506
1507    /// Cross-check the peer-plane TLS knobs.
1508    ///
1509    /// `peer_tls_cert` and `peer_tls_key` must both be set or
1510    /// both be unset. `peer_tls_ca` is independent (it controls
1511    /// optional mutual TLS) but only meaningful when the cert /
1512    /// key pair is set. Each per-DC profile in
1513    /// `peer_tls_profiles` is validated by
1514    /// [`ConfTlsProfile::validate`]; the per-DC profile names
1515    /// must be non-empty.
1516    fn validate_peer_tls(&self) -> Result<(), ConfError> {
1517        validate_tls_pair(
1518            "peer_tls_cert",
1519            "peer_tls_key",
1520            self.peer_tls_cert.as_deref(),
1521            self.peer_tls_key.as_deref(),
1522        )?;
1523        if self.peer_tls_ca.is_some() && self.peer_tls_cert.is_none() {
1524            return Err(ConfError::BadServer {
1525                field: "peer_tls_ca",
1526                value: self
1527                    .peer_tls_ca
1528                    .as_ref()
1529                    .map_or_else(String::new, |p| p.display().to_string()),
1530                reason: "requires peer_tls_cert and peer_tls_key to also be set".into(),
1531            });
1532        }
1533        for (dc, profile) in &self.peer_tls_profiles {
1534            if dc.is_empty() {
1535                return Err(ConfError::BadServer {
1536                    field: "peer_tls_profiles",
1537                    value: String::new(),
1538                    reason: "per-DC TLS profile name must not be empty".into(),
1539                });
1540            }
1541            profile.validate(dc)?;
1542        }
1543        Ok(())
1544    }
1545
1546    /// Cross-check the `transport:` selection against the
1547    /// QUIC cert / key knobs.
1548    ///
1549    /// `transport: quic` requires both [`Self::quic_cert_file`]
1550    /// and [`Self::quic_key_file`] to be set; the QUIC listener
1551    /// in `dynomite::net::quic::QuicConfig` cannot bind without
1552    /// a server cert chain and matching private key. The fields
1553    /// are tolerated (but ignored) when `transport: tcp` so
1554    /// operators can switch transports by toggling a single
1555    /// directive without rewriting the whole pool block.
1556    fn validate_transport(&self) -> Result<(), ConfError> {
1557        let resolved = self.transport.unwrap_or_default();
1558        if resolved != Transport::Quic {
1559            return Ok(());
1560        }
1561        match (
1562            self.quic_cert_file.as_deref(),
1563            self.quic_key_file.as_deref(),
1564        ) {
1565            (Some(_), Some(_)) => Ok(()),
1566            (None, _) => Err(ConfError::BadServer {
1567                field: "quic_cert_file",
1568                value: String::new(),
1569                reason: "transport: quic requires quic_cert_file to be set".into(),
1570            }),
1571            (Some(_), None) => Err(ConfError::BadServer {
1572                field: "quic_key_file",
1573                value: String::new(),
1574                reason: "transport: quic requires quic_key_file to be set".into(),
1575            }),
1576        }
1577    }
1578}
1579
1580impl fmt::Display for ConfPool {
1581    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1582        // We render the pool body by re-serializing through serde_yaml
1583        // so the round-trip is well defined; this is used by `test_conf`
1584        // and rustdoc examples.
1585        match serde_yaml::to_string(self) {
1586            Ok(s) => f.write_str(&s),
1587            Err(_) => Err(fmt::Error),
1588        }
1589    }
1590}
1591
1592fn check_positive(field: &'static str, v: Option<i64>) -> Result<(), ConfError> {
1593    if let Some(n) = v {
1594        if n <= 0 {
1595            return Err(ConfError::OutOfRange {
1596                field,
1597                value: n,
1598                reason: "must be a positive number",
1599            });
1600        }
1601    }
1602    Ok(())
1603}
1604
1605fn check_non_negative(field: &'static str, v: Option<i64>) -> Result<(), ConfError> {
1606    if let Some(n) = v {
1607        if n < 0 {
1608            return Err(ConfError::OutOfRange {
1609                field,
1610                value: n,
1611                reason: "must be a non-negative number",
1612            });
1613        }
1614    }
1615    Ok(())
1616}
1617
1618/// Custom deserializer for `data_store:` that accepts either the
1619/// historical integer form (`0`, `1`, `2`) or the textual form
1620/// (`valkey`, `memcache`, `dyniak`, plus the back-compat alias
1621/// `redis`). Both shapes normalise to the integer code that the
1622/// rest of the engine consumes.
1623fn deserialize_data_store<'de, D>(de: D) -> Result<Option<i64>, D::Error>
1624where
1625    D: serde::Deserializer<'de>,
1626{
1627    use serde::de::{self, Visitor};
1628    use std::fmt;
1629
1630    struct V;
1631    impl<'de> Visitor<'de> for V {
1632        type Value = Option<i64>;
1633
1634        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1635            f.write_str(
1636                "a data_store value: integer (0, 1, 2) or string (valkey, memcache, dyniak)",
1637            )
1638        }
1639
1640        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1641            Ok(None)
1642        }
1643
1644        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1645            Ok(None)
1646        }
1647
1648        fn visit_some<D2: serde::Deserializer<'de>>(
1649            self,
1650            de: D2,
1651        ) -> Result<Self::Value, D2::Error> {
1652            de.deserialize_any(V)
1653        }
1654
1655        fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
1656            DataStore::from_int(v)
1657                .map(|d| Some(d.as_int()))
1658                .map_err(|e| E::custom(e.to_string()))
1659        }
1660
1661        fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
1662            let n = i64::try_from(v).map_err(|_| E::custom("data_store integer overflow"))?;
1663            self.visit_i64(n)
1664        }
1665
1666        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1667            DataStore::from_name(v)
1668                .map(|d| Some(d.as_int()))
1669                .map_err(|_| {
1670                    E::custom(format!(
1671                        "data_store: unknown name '{v}'; expected one of: valkey, memcache, dyniak"
1672                    ))
1673                })
1674        }
1675
1676        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
1677            self.visit_str(&v)
1678        }
1679    }
1680
1681    de.deserialize_any(V)
1682}
1683
1684#[cfg(test)]
1685mod tests {
1686    use super::*;
1687
1688    fn pool() -> ConfPool {
1689        ConfPool {
1690            listen: Some(ConfListen::parse("listen", "127.0.0.1:8102").unwrap()),
1691            servers: Some(Servers::from_vec(vec![ConfServer::parse(
1692                "127.0.0.1:6379:1",
1693            )
1694            .unwrap()])),
1695            tokens: Some(TokenList::parse("0").unwrap()),
1696            ..ConfPool::default()
1697        }
1698    }
1699
1700    #[test]
1701    fn validate_minimal_post_finalize() {
1702        let mut p = pool();
1703        p.apply_defaults();
1704        p.validate("dyn_o_mite").unwrap();
1705    }
1706
1707    #[test]
1708    fn missing_listen_rejected() {
1709        let mut p = pool();
1710        p.listen = None;
1711        p.apply_defaults();
1712        assert!(matches!(
1713            p.validate("p"),
1714            Err(ConfError::MissingRequired("listen"))
1715        ));
1716    }
1717
1718    #[test]
1719    fn out_of_range_mbuf_rejected() {
1720        let mut p = pool();
1721        p.mbuf_size = Some(127);
1722        p.apply_defaults();
1723        assert!(matches!(p.validate("p"), Err(ConfError::OutOfRange { .. })));
1724    }
1725
1726    #[test]
1727    fn distribution_field_round_trips_through_yaml() {
1728        let yaml = r"
1729p:
1730  listen: 127.0.0.1:8102
1731  dyn_listen: 127.0.0.1:8101
1732  tokens: '0'
1733  servers:
1734  - 127.0.0.1:6379:1
1735  data_store: 0
1736  distribution: random_slicing
1737  distribution_shadow: vnode
1738  hash: murmur3_x64_64
1739";
1740        let parsed: std::collections::BTreeMap<String, ConfPool> =
1741            serde_yaml::from_str(yaml).unwrap();
1742        let pool = parsed.get("p").unwrap();
1743        assert_eq!(pool.distribution, Some(Distribution::RandomSlicing));
1744        assert_eq!(pool.distribution_shadow, Some(Distribution::Vnode));
1745        assert_eq!(pool.hash, Some(HashType::Murmur3X64_64));
1746        assert_eq!(pool.resolved_distribution(), Distribution::RandomSlicing);
1747    }
1748
1749    #[test]
1750    fn distribution_legacy_alias_resolves_to_vnode() {
1751        let mut p = pool();
1752        p.distribution = Some(Distribution::Ketama);
1753        assert_eq!(p.resolved_distribution(), Distribution::Vnode);
1754        p.distribution = Some(Distribution::Modula);
1755        assert_eq!(p.resolved_distribution(), Distribution::Vnode);
1756        p.distribution = Some(Distribution::Random);
1757        assert_eq!(p.resolved_distribution(), Distribution::Vnode);
1758    }
1759
1760    #[test]
1761    fn distribution_default_unset_is_vnode() {
1762        let p = pool();
1763        assert!(p.distribution.is_none());
1764        assert_eq!(p.resolved_distribution(), Distribution::Vnode);
1765    }
1766
1767    #[test]
1768    fn mbuf_size_not_multiple_of_16_rejected() {
1769        let mut p = pool();
1770        p.mbuf_size = Some(513);
1771        p.apply_defaults();
1772        assert!(matches!(p.validate("p"), Err(ConfError::OutOfRange { .. })));
1773    }
1774
1775    #[test]
1776    fn pem_required_when_secure() {
1777        let mut p = pool();
1778        p.secure_server_option = Some("datacenter".to_string());
1779        p.pem_key_file = Some(String::new());
1780        p.apply_defaults();
1781        // apply_defaults restores pem_key_file because it's `Some("")`,
1782        // which is non-None; so we expect MissingRequired("pem_key_file").
1783        assert!(matches!(
1784            p.validate("p"),
1785            Err(ConfError::MissingRequired("pem_key_file"))
1786        ));
1787    }
1788
1789    #[test]
1790    fn data_store_out_of_range_rejected() {
1791        let mut p = pool();
1792        p.data_store = Some(7);
1793        p.apply_defaults();
1794        assert!(matches!(p.validate("p"), Err(ConfError::BadDataStore(7))));
1795    }
1796
1797    /// Lock serialising tests that mutate the process-wide
1798    /// `DYNIAK_SUPPORTED` flag. cargo test runs tests on multiple
1799    /// threads; without serialisation a parallel test can flip
1800    /// the flag back before the assertion runs.
1801    static DYNIAK_FLAG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1802
1803    #[test]
1804    fn data_store_dyniak_requires_riak_feature() {
1805        let _g = DYNIAK_FLAG_LOCK
1806            .lock()
1807            .unwrap_or_else(std::sync::PoisonError::into_inner);
1808        // Default state: dyniak support flag is off; selecting
1809        // dyniak must be rejected with the documented message.
1810        let prev = crate::conf::is_dyniak_supported();
1811        crate::conf::set_dyniak_supported(false);
1812        let mut p = pool();
1813        p.data_store = Some(2);
1814        p.noxu_path = Some("/scratch/test".into());
1815        p.apply_defaults();
1816        let err = p.validate("p");
1817        crate::conf::set_dyniak_supported(prev);
1818        match err {
1819            Err(ConfError::BadDyniakConfig(msg)) => {
1820                assert!(msg.contains("--features riak"), "unexpected message: {msg}");
1821            }
1822            other => panic!("expected BadDyniakConfig, got {other:?}"),
1823        }
1824    }
1825
1826    #[test]
1827    fn data_store_dyniak_requires_path() {
1828        let _g = DYNIAK_FLAG_LOCK
1829            .lock()
1830            .unwrap_or_else(std::sync::PoisonError::into_inner);
1831        let prev = crate::conf::is_dyniak_supported();
1832        crate::conf::set_dyniak_supported(true);
1833        let mut p = pool();
1834        p.data_store = Some(2);
1835        p.noxu_path = None;
1836        p.apply_defaults();
1837        let err = p.validate("p");
1838        crate::conf::set_dyniak_supported(prev);
1839        match err {
1840            Err(ConfError::BadDyniakConfig(msg)) => {
1841                assert!(msg.contains("noxu_path"), "unexpected message: {msg}");
1842            }
1843            other => panic!("expected BadDyniakConfig, got {other:?}"),
1844        }
1845    }
1846
1847    #[test]
1848    fn data_store_dyniak_yaml_round_trip_string_form() {
1849        // String form `data_store: dyniak` and integer form
1850        // `data_store: 2` both normalise to integer 2 on parse.
1851        let yaml = r"
1852listen: 127.0.0.1:8102
1853servers:
1854- 127.0.0.1:6379:1
1855tokens: '0'
1856data_store: dyniak
1857noxu_path: /scratch/test
1858";
1859        let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
1860        assert_eq!(p.data_store, Some(2));
1861        assert_eq!(
1862            p.noxu_path.as_deref(),
1863            Some(std::path::Path::new("/scratch/test"))
1864        );
1865        // Re-emit and re-parse: round-trip is stable.
1866        let dumped = serde_yaml::to_string(&p).unwrap();
1867        let p2: ConfPool = serde_yaml::from_str(&dumped).unwrap();
1868        assert_eq!(p2.data_store, p.data_store);
1869        assert_eq!(p2.noxu_path, p.noxu_path);
1870    }
1871
1872    #[test]
1873    fn data_store_redis_alias_maps_to_valkey() {
1874        // The historical `redis` name must keep loading and map
1875        // to the Valkey variant (integer 0).
1876        let yaml = r"
1877listen: 127.0.0.1:8102
1878servers:
1879- 127.0.0.1:6379:1
1880tokens: '0'
1881data_store: redis
1882";
1883        let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
1884        assert_eq!(p.data_store, Some(0));
1885    }
1886
1887    #[test]
1888    fn data_store_yaml_int_form_still_works() {
1889        let yaml = r"
1890listen: 127.0.0.1:8102
1891servers:
1892- 127.0.0.1:6379:1
1893tokens: '0'
1894data_store: 2
1895noxu_path: /scratch/test
1896";
1897        let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
1898        assert_eq!(p.data_store, Some(2));
1899    }
1900
1901    #[test]
1902    fn data_store_string_form_unknown_rejected() {
1903        let yaml = r"
1904listen: 127.0.0.1:8102
1905servers:
1906- 127.0.0.1:6379:1
1907tokens: '0'
1908data_store: postgres
1909";
1910        let err = serde_yaml::from_str::<ConfPool>(yaml).unwrap_err();
1911        let msg = err.to_string();
1912        assert!(
1913            msg.contains("unknown name") || msg.contains("data_store"),
1914            "unexpected message: {msg}"
1915        );
1916    }
1917
1918    #[test]
1919    fn hash_tag_must_be_two_chars() {
1920        let mut p = pool();
1921        p.hash_tag = Some("abc".to_string());
1922        p.apply_defaults();
1923        assert!(matches!(p.validate("p"), Err(ConfError::BadHashTag(_))));
1924    }
1925
1926    #[test]
1927    fn empty_servers_rejected() {
1928        let mut p = pool();
1929        p.servers = Some(Servers::from_vec(vec![]));
1930        p.apply_defaults();
1931        assert!(matches!(
1932            p.validate("p"),
1933            Err(ConfError::MissingRequired("servers"))
1934        ));
1935    }
1936
1937    #[test]
1938    fn log_format_known_values_accepted() {
1939        for value in ["default", "rfc5424", "rfc3164", "json", "ndjson", "DEFAULT"] {
1940            let mut p = pool();
1941            p.log_format = Some(value.to_string());
1942            p.apply_defaults();
1943            assert!(p.validate("p").is_ok(), "value {value:?} should validate");
1944        }
1945    }
1946
1947    #[test]
1948    fn log_format_unknown_rejected() {
1949        let mut p = pool();
1950        p.log_format = Some("yaml".to_string());
1951        p.apply_defaults();
1952        let err = p.validate("p").unwrap_err();
1953        assert!(
1954            matches!(
1955                err,
1956                ConfError::BadServer {
1957                    field: "log_format",
1958                    ..
1959                }
1960            ),
1961            "unexpected error: {err:?}"
1962        );
1963    }
1964
1965    #[test]
1966    fn observability_block_round_trips() {
1967        let yaml = r"
1968observability:
1969  otlp_logs_endpoint: http://collector:4317
1970  service_name: dynomited
1971listen: 127.0.0.1:8102
1972servers:
1973- 127.0.0.1:6379:1
1974tokens: '0'
1975";
1976        let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
1977        let obs = p.observability.as_ref().expect("observability set");
1978        assert_eq!(
1979            obs.otlp_logs_endpoint.as_deref(),
1980            Some("http://collector:4317")
1981        );
1982        assert_eq!(obs.service_name.as_deref(), Some("dynomited"));
1983    }
1984
1985    #[test]
1986    fn bucket_types_round_trip() {
1987        let yaml = r"
1988listen: 127.0.0.1:8102
1989servers:
1990- 127.0.0.1:6379:1
1991tokens: '0'
1992bucket_types:
1993- name: hot
1994  read_consistency: DC_QUORUM
1995  write_consistency: DC_EACH_SAFE_QUORUM
1996  n_val: 3
1997- name: cold
1998  read_consistency: DC_ONE
1999  write_consistency: DC_ONE
2000  n_val: 1
2001default_bucket_type: cold
2002";
2003        let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
2004        assert_eq!(p.bucket_types.len(), 2);
2005        assert_eq!(p.bucket_types[0].name, "hot");
2006        assert_eq!(p.bucket_types[0].n_val, 3);
2007        assert_eq!(
2008            p.bucket_types[0].read_level().unwrap(),
2009            crate::conf::ConsistencyLevel::DcQuorum,
2010        );
2011        assert_eq!(p.default_bucket_type.as_deref(), Some("cold"));
2012        // Re-emit and re-parse: round-trip preserves the data.
2013        let dumped = serde_yaml::to_string(&p).unwrap();
2014        let p2: ConfPool = serde_yaml::from_str(&dumped).unwrap();
2015        assert_eq!(p2.bucket_types, p.bucket_types);
2016        assert_eq!(p2.default_bucket_type, p.default_bucket_type);
2017    }
2018
2019    #[test]
2020    fn bucket_types_default_is_empty() {
2021        let mut p = pool();
2022        p.apply_defaults();
2023        assert!(p.bucket_types.is_empty());
2024        assert!(p.default_bucket_type.is_none());
2025        assert!(p.validate("p").is_ok());
2026    }
2027
2028    #[test]
2029    fn duplicate_bucket_type_name_rejected() {
2030        let mut p = pool();
2031        p.bucket_types = vec![
2032            ConfBucketType {
2033                name: "a".into(),
2034                read_consistency: "DC_ONE".into(),
2035                write_consistency: "DC_ONE".into(),
2036                n_val: 0,
2037            },
2038            ConfBucketType {
2039                name: "a".into(),
2040                read_consistency: "DC_ONE".into(),
2041                write_consistency: "DC_ONE".into(),
2042                n_val: 0,
2043            },
2044        ];
2045        p.apply_defaults();
2046        let err = p.validate("p").unwrap_err();
2047        assert!(
2048            matches!(
2049                err,
2050                ConfError::BadServer {
2051                    field: "bucket_types",
2052                    ..
2053                }
2054            ),
2055            "unexpected error: {err:?}",
2056        );
2057    }
2058
2059    #[test]
2060    fn bucket_type_unknown_consistency_rejected() {
2061        let mut p = pool();
2062        p.bucket_types = vec![ConfBucketType {
2063            name: "a".into(),
2064            read_consistency: "DC_PURPLE".into(),
2065            write_consistency: "DC_ONE".into(),
2066            n_val: 0,
2067        }];
2068        p.apply_defaults();
2069        let err = p.validate("p").unwrap_err();
2070        assert!(matches!(err, ConfError::BadConsistency { .. }));
2071    }
2072
2073    #[test]
2074    fn unknown_default_bucket_type_rejected() {
2075        let mut p = pool();
2076        p.default_bucket_type = Some("missing".into());
2077        p.apply_defaults();
2078        let err = p.validate("p").unwrap_err();
2079        assert!(matches!(
2080            err,
2081            ConfError::BadServer {
2082                field: "default_bucket_type",
2083                ..
2084            }
2085        ));
2086    }
2087
2088    #[test]
2089    fn hinted_handoff_default_off_with_canonical_constants() {
2090        let mut p = pool();
2091        p.apply_defaults();
2092        assert_eq!(p.enable_hinted_handoff, Some(false));
2093        assert_eq!(p.hint_ttl_seconds, Some(defaults::HINT_TTL_SECONDS));
2094        assert_eq!(p.hint_store_max_bytes, Some(defaults::HINT_STORE_MAX_BYTES));
2095        assert_eq!(
2096            p.hint_drain_interval_ms,
2097            Some(defaults::HINT_DRAIN_INTERVAL_MS)
2098        );
2099        assert!(p.validate("p").is_ok());
2100    }
2101
2102    #[test]
2103    fn hinted_handoff_yaml_round_trip() {
2104        let yaml = r"
2105listen: 127.0.0.1:8102
2106servers:
2107- 127.0.0.1:6379:1
2108tokens: '0'
2109enable_hinted_handoff: true
2110hint_ttl_seconds: 7200
2111hint_store_max_bytes: 8388608
2112hint_drain_interval_ms: 5000
2113hint_dir: /scratch/dynomite-hints
2114";
2115        let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
2116        assert_eq!(p.enable_hinted_handoff, Some(true));
2117        assert_eq!(p.hint_ttl_seconds, Some(7200));
2118        assert_eq!(p.hint_store_max_bytes, Some(8_388_608));
2119        assert_eq!(p.hint_drain_interval_ms, Some(5_000));
2120        assert_eq!(
2121            p.hint_dir.as_deref(),
2122            Some(std::path::Path::new("/scratch/dynomite-hints"))
2123        );
2124        let dumped = serde_yaml::to_string(&p).unwrap();
2125        let p2: ConfPool = serde_yaml::from_str(&dumped).unwrap();
2126        assert_eq!(p2.enable_hinted_handoff, p.enable_hinted_handoff);
2127        assert_eq!(p2.hint_ttl_seconds, p.hint_ttl_seconds);
2128        assert_eq!(p2.hint_store_max_bytes, p.hint_store_max_bytes);
2129        assert_eq!(p2.hint_drain_interval_ms, p.hint_drain_interval_ms);
2130        assert_eq!(p2.hint_dir, p.hint_dir);
2131    }
2132
2133    #[test]
2134    fn hinted_handoff_zero_ttl_rejected_when_enabled() {
2135        let mut p = pool();
2136        p.enable_hinted_handoff = Some(true);
2137        p.hint_ttl_seconds = Some(0);
2138        p.apply_defaults();
2139        // apply_defaults() does NOT overwrite Some(0) with the
2140        // default; the validator should reject it.
2141        let err = p.validate("p").unwrap_err();
2142        assert!(matches!(
2143            err,
2144            ConfError::BadServer {
2145                field: "hint_ttl_seconds",
2146                ..
2147            }
2148        ));
2149    }
2150
2151    #[test]
2152    fn hinted_handoff_zero_max_bytes_rejected_when_enabled() {
2153        let mut p = pool();
2154        p.enable_hinted_handoff = Some(true);
2155        p.hint_store_max_bytes = Some(0);
2156        p.apply_defaults();
2157        let err = p.validate("p").unwrap_err();
2158        assert!(matches!(
2159            err,
2160            ConfError::BadServer {
2161                field: "hint_store_max_bytes",
2162                ..
2163            }
2164        ));
2165    }
2166
2167    #[test]
2168    fn hinted_handoff_zero_values_ignored_when_disabled() {
2169        // With handoff off, the validator must NOT reject
2170        // out-of-range values: operators may legitimately leave
2171        // them at zero with handoff off.
2172        let mut p = pool();
2173        p.enable_hinted_handoff = Some(false);
2174        p.hint_ttl_seconds = Some(0);
2175        p.hint_store_max_bytes = Some(0);
2176        p.hint_drain_interval_ms = Some(0);
2177        p.apply_defaults();
2178        assert!(p.validate("p").is_ok());
2179    }
2180
2181    #[test]
2182    fn riak_block_validates_when_unset() {
2183        let mut p = pool();
2184        p.riak = Some(ConfRiak::default());
2185        p.apply_defaults();
2186        assert!(p.validate("p").is_ok());
2187    }
2188
2189    #[test]
2190    fn riak_block_validates_with_addresses() {
2191        let mut p = pool();
2192        p.riak = Some(ConfRiak {
2193            pbc_listen: Some("127.0.0.1:8087".into()),
2194            http_listen: Some("127.0.0.1:8098".into()),
2195            ..ConfRiak::default()
2196        });
2197        p.apply_defaults();
2198        assert!(p.validate("p").is_ok());
2199    }
2200
2201    #[test]
2202    fn riak_block_rejects_bad_pbc_addr() {
2203        let mut p = pool();
2204        p.riak = Some(ConfRiak {
2205            pbc_listen: Some(String::new()),
2206            ..ConfRiak::default()
2207        });
2208        p.apply_defaults();
2209        assert!(matches!(p.validate("p"), Err(ConfError::BadServer { .. })));
2210    }
2211
2212    #[test]
2213    fn riak_block_rejects_segment_above_full_sweep() {
2214        let mut p = pool();
2215        p.riak = Some(ConfRiak {
2216            aae_segment_interval_seconds: Some(120),
2217            aae_full_sweep_interval_seconds: Some(60),
2218            ..ConfRiak::default()
2219        });
2220        p.apply_defaults();
2221        assert!(matches!(p.validate("p"), Err(ConfError::BadServer { .. })));
2222    }
2223
2224    #[test]
2225    fn riak_block_round_trips_through_yaml() {
2226        let yaml = r"
2227p:
2228  listen: 127.0.0.1:1
2229  dyn_listen: 127.0.0.1:2
2230  tokens: '0'
2231  servers:
2232  - 127.0.0.1:3:1
2233  data_store: 0
2234  riak:
2235    pbc_listen: 127.0.0.1:8087
2236    http_listen: 127.0.0.1:8098
2237    aae_enabled: true
2238    aae_full_sweep_interval_seconds: 3600
2239    aae_segment_interval_seconds: 30
2240";
2241        let cfg: std::collections::BTreeMap<String, ConfPool> = serde_yaml::from_str(yaml).unwrap();
2242        let p = cfg.get("p").unwrap();
2243        let r = p.riak.as_ref().unwrap();
2244        assert_eq!(r.pbc_listen.as_deref(), Some("127.0.0.1:8087"));
2245        assert_eq!(r.http_listen.as_deref(), Some("127.0.0.1:8098"));
2246        assert_eq!(r.aae_enabled, Some(true));
2247        assert_eq!(r.aae_full_sweep_interval_seconds, Some(3600));
2248        assert_eq!(r.aae_segment_interval_seconds, Some(30));
2249    }
2250
2251    #[test]
2252    fn peer_tls_pair_unset_is_ok() {
2253        let mut p = pool();
2254        p.apply_defaults();
2255        assert!(p.validate("p").is_ok(), "plaintext default must validate");
2256    }
2257
2258    #[test]
2259    fn peer_tls_pair_both_set_is_ok() {
2260        let mut p = pool();
2261        p.peer_tls_cert = Some(std::path::PathBuf::from("/etc/dynomite/peer.crt"));
2262        p.peer_tls_key = Some(std::path::PathBuf::from("/etc/dynomite/peer.key"));
2263        p.apply_defaults();
2264        assert!(p.validate("p").is_ok());
2265    }
2266
2267    #[test]
2268    fn peer_tls_cert_without_key_rejected() {
2269        let mut p = pool();
2270        p.peer_tls_cert = Some(std::path::PathBuf::from("/x.crt"));
2271        p.apply_defaults();
2272        let err = p.validate("p").unwrap_err();
2273        assert!(
2274            matches!(
2275                err,
2276                ConfError::BadServer {
2277                    field: "peer_tls_cert",
2278                    ..
2279                }
2280            ),
2281            "got {err:?}"
2282        );
2283    }
2284
2285    #[test]
2286    fn peer_tls_key_without_cert_rejected() {
2287        let mut p = pool();
2288        p.peer_tls_key = Some(std::path::PathBuf::from("/x.key"));
2289        p.apply_defaults();
2290        let err = p.validate("p").unwrap_err();
2291        assert!(
2292            matches!(
2293                err,
2294                ConfError::BadServer {
2295                    field: "peer_tls_key",
2296                    ..
2297                }
2298            ),
2299            "got {err:?}"
2300        );
2301    }
2302
2303    #[test]
2304    fn peer_tls_ca_without_cert_rejected() {
2305        let mut p = pool();
2306        p.peer_tls_ca = Some(std::path::PathBuf::from("/x.ca"));
2307        p.apply_defaults();
2308        let err = p.validate("p").unwrap_err();
2309        assert!(
2310            matches!(
2311                err,
2312                ConfError::BadServer {
2313                    field: "peer_tls_ca",
2314                    ..
2315                }
2316            ),
2317            "got {err:?}"
2318        );
2319    }
2320
2321    #[test]
2322    fn riak_tls_cert_without_key_rejected() {
2323        let mut p = pool();
2324        p.riak = Some(ConfRiak {
2325            pbc_listen: Some("127.0.0.1:8087".into()),
2326            tls_cert: Some(std::path::PathBuf::from("/x.crt")),
2327            ..ConfRiak::default()
2328        });
2329        p.apply_defaults();
2330        let err = p.validate("p").unwrap_err();
2331        assert!(
2332            matches!(
2333                err,
2334                ConfError::BadServer {
2335                    field: "tls_cert",
2336                    ..
2337                }
2338            ),
2339            "got {err:?}"
2340        );
2341    }
2342
2343    #[test]
2344    fn riak_tls_pair_both_set_is_ok() {
2345        let mut p = pool();
2346        p.riak = Some(ConfRiak {
2347            pbc_listen: Some("127.0.0.1:8087".into()),
2348            tls_cert: Some(std::path::PathBuf::from("/x.crt")),
2349            tls_key: Some(std::path::PathBuf::from("/x.key")),
2350            ..ConfRiak::default()
2351        });
2352        p.apply_defaults();
2353        assert!(p.validate("p").is_ok());
2354    }
2355
2356    #[test]
2357    fn riak_quic_listen_requires_tls_pair() {
2358        let mut p = pool();
2359        p.riak = Some(ConfRiak {
2360            quic_listen: Some("127.0.0.1:8089".into()),
2361            ..ConfRiak::default()
2362        });
2363        p.apply_defaults();
2364        let Err(ConfError::BadServer { field, .. }) = p.validate("p") else {
2365            panic!("quic_listen without tls_cert/tls_key must be rejected");
2366        };
2367        assert_eq!(field, "quic_listen");
2368    }
2369
2370    #[test]
2371    fn riak_quic_listen_with_tls_pair_is_ok() {
2372        let mut p = pool();
2373        p.riak = Some(ConfRiak {
2374            quic_listen: Some("127.0.0.1:8089".into()),
2375            tls_cert: Some(std::path::PathBuf::from("/x.crt")),
2376            tls_key: Some(std::path::PathBuf::from("/x.key")),
2377            ..ConfRiak::default()
2378        });
2379        p.apply_defaults();
2380        assert!(p.validate("p").is_ok());
2381    }
2382
2383    #[test]
2384    fn riak_quic_listen_rejects_bad_addr() {
2385        let mut p = pool();
2386        p.riak = Some(ConfRiak {
2387            quic_listen: Some("not-an-addr".into()),
2388            tls_cert: Some(std::path::PathBuf::from("/x.crt")),
2389            tls_key: Some(std::path::PathBuf::from("/x.key")),
2390            ..ConfRiak::default()
2391        });
2392        p.apply_defaults();
2393        assert!(p.validate("p").is_err());
2394    }
2395
2396    #[test]
2397    fn riak_wasm_modules_yaml_round_trip() {
2398        let dir = tempfile::tempdir().unwrap();
2399        let m1 = dir.path().join("identity.wasm");
2400        let m2 = dir.path().join("sum.wasm");
2401        std::fs::write(&m1, b"\0asm\x01\0\0\0").unwrap();
2402        std::fs::write(&m2, b"\0asm\x01\0\0\0").unwrap();
2403        let yaml = format!(
2404            r"
2405listen: 127.0.0.1:8102
2406servers:
2407- 127.0.0.1:6379:1
2408tokens: '0'
2409riak:
2410  pbc_listen: 127.0.0.1:8087
2411  wasm_modules:
2412  - id: identity
2413    path: {m1}
2414  - id: sum
2415    path: {m2}
2416",
2417            m1 = m1.display(),
2418            m2 = m2.display(),
2419        );
2420        let p: ConfPool = serde_yaml::from_str(&yaml).unwrap();
2421        let r = p.riak.as_ref().unwrap();
2422        let mods = r.wasm_modules.as_ref().unwrap();
2423        assert_eq!(mods.len(), 2);
2424        assert_eq!(mods[0].id, "identity");
2425        assert_eq!(mods[0].path, m1);
2426        assert_eq!(mods[1].id, "sum");
2427        assert_eq!(mods[1].path, m2);
2428        // Round-trip back to YAML and re-parse.
2429        let dumped = serde_yaml::to_string(&p).unwrap();
2430        let p2: ConfPool = serde_yaml::from_str(&dumped).unwrap();
2431        assert_eq!(p2.riak.unwrap().wasm_modules, r.wasm_modules);
2432    }
2433
2434    #[test]
2435    fn riak_wasm_modules_unique_ids_required() {
2436        let dir = tempfile::tempdir().unwrap();
2437        let path = dir.path().join("m.wasm");
2438        std::fs::write(&path, b"\0").unwrap();
2439        let r = ConfRiak {
2440            wasm_modules: Some(vec![
2441                ConfRiakWasmModule {
2442                    id: "m".into(),
2443                    path: path.clone(),
2444                },
2445                ConfRiakWasmModule {
2446                    id: "m".into(),
2447                    path: path.clone(),
2448                },
2449            ]),
2450            ..ConfRiak::default()
2451        };
2452        let err = r.validate().unwrap_err();
2453        assert!(matches!(
2454            err,
2455            ConfError::BadServer {
2456                field: "wasm_modules.id",
2457                ..
2458            }
2459        ));
2460    }
2461
2462    #[test]
2463    fn riak_wasm_modules_path_must_exist() {
2464        let r = ConfRiak {
2465            wasm_modules: Some(vec![ConfRiakWasmModule {
2466                id: "missing".into(),
2467                path: std::path::PathBuf::from("/no/such/path/at/all.wasm"),
2468            }]),
2469            ..ConfRiak::default()
2470        };
2471        let err = r.validate().unwrap_err();
2472        assert!(matches!(
2473            err,
2474            ConfError::BadServer {
2475                field: "wasm_modules.path",
2476                ..
2477            }
2478        ));
2479    }
2480
2481    #[test]
2482    fn riak_wasm_modules_empty_id_rejected() {
2483        let dir = tempfile::tempdir().unwrap();
2484        let path = dir.path().join("m.wasm");
2485        std::fs::write(&path, b"\0").unwrap();
2486        let r = ConfRiak {
2487            wasm_modules: Some(vec![ConfRiakWasmModule {
2488                id: String::new(),
2489                path,
2490            }]),
2491            ..ConfRiak::default()
2492        };
2493        let err = r.validate().unwrap_err();
2494        assert!(matches!(
2495            err,
2496            ConfError::BadServer {
2497                field: "wasm_modules.id",
2498                ..
2499            }
2500        ));
2501    }
2502
2503    #[test]
2504    fn peer_tls_profile_pair_unset_is_ok() {
2505        let p = ConfTlsProfile::default();
2506        assert!(p.validate("dc1").is_ok());
2507    }
2508
2509    #[test]
2510    fn peer_tls_profile_cert_without_key_rejected() {
2511        let p = ConfTlsProfile {
2512            cert: Some(std::path::PathBuf::from("/x.crt")),
2513            ..ConfTlsProfile::default()
2514        };
2515        let err = p.validate("dc1").unwrap_err();
2516        assert!(matches!(
2517            err,
2518            ConfError::BadServer {
2519                field: "peer_tls_profiles.cert",
2520                ..
2521            }
2522        ));
2523    }
2524
2525    #[test]
2526    fn peer_tls_profile_key_without_cert_rejected() {
2527        let p = ConfTlsProfile {
2528            key: Some(std::path::PathBuf::from("/x.key")),
2529            ..ConfTlsProfile::default()
2530        };
2531        let err = p.validate("dc1").unwrap_err();
2532        assert!(matches!(
2533            err,
2534            ConfError::BadServer {
2535                field: "peer_tls_profiles.key",
2536                ..
2537            }
2538        ));
2539    }
2540
2541    #[test]
2542    fn peer_tls_profile_ca_without_cert_rejected() {
2543        let p = ConfTlsProfile {
2544            ca: Some(std::path::PathBuf::from("/x.ca")),
2545            ..ConfTlsProfile::default()
2546        };
2547        let err = p.validate("dc1").unwrap_err();
2548        assert!(matches!(
2549            err,
2550            ConfError::BadServer {
2551                field: "peer_tls_profiles.ca",
2552                ..
2553            }
2554        ));
2555    }
2556
2557    #[test]
2558    fn peer_tls_profiles_empty_dc_name_rejected() {
2559        let mut p = pool();
2560        p.peer_tls_profiles.insert(
2561            String::new(),
2562            ConfTlsProfile {
2563                cert: Some(std::path::PathBuf::from("/x.crt")),
2564                key: Some(std::path::PathBuf::from("/x.key")),
2565                ca: None,
2566            },
2567        );
2568        p.apply_defaults();
2569        let err = p.validate("p").unwrap_err();
2570        assert!(matches!(
2571            err,
2572            ConfError::BadServer {
2573                field: "peer_tls_profiles",
2574                ..
2575            }
2576        ));
2577    }
2578
2579    #[test]
2580    fn peer_tls_profiles_per_dc_pair_validates() {
2581        let mut p = pool();
2582        p.peer_tls_profiles.insert(
2583            "dc1".into(),
2584            ConfTlsProfile {
2585                cert: Some(std::path::PathBuf::from("/dc1.crt")),
2586                key: Some(std::path::PathBuf::from("/dc1.key")),
2587                ca: None,
2588            },
2589        );
2590        p.apply_defaults();
2591        assert!(p.validate("p").is_ok());
2592    }
2593
2594    #[test]
2595    fn peer_tls_profiles_per_dc_cert_without_key_rejected() {
2596        let mut p = pool();
2597        p.peer_tls_profiles.insert(
2598            "dc1".into(),
2599            ConfTlsProfile {
2600                cert: Some(std::path::PathBuf::from("/dc1.crt")),
2601                key: None,
2602                ca: None,
2603            },
2604        );
2605        p.apply_defaults();
2606        let err = p.validate("p").unwrap_err();
2607        assert!(matches!(
2608            err,
2609            ConfError::BadServer {
2610                field: "peer_tls_profiles.cert",
2611                ..
2612            }
2613        ));
2614    }
2615
2616    #[test]
2617    fn peer_tls_profiles_yaml_round_trip() {
2618        let yaml = r"
2619listen: 127.0.0.1:8102
2620servers:
2621- 127.0.0.1:6379:1
2622tokens: '0'
2623peer_tls_profiles:
2624  dc1:
2625    cert: /etc/dynomite/dc1.pem
2626    key: /etc/dynomite/dc1.key
2627    ca: /etc/dynomite/dc1-ca.pem
2628  dc2:
2629    cert: /etc/dynomite/dc2.pem
2630    key: /etc/dynomite/dc2.key
2631";
2632        let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
2633        assert_eq!(p.peer_tls_profiles.len(), 2);
2634        assert_eq!(
2635            p.peer_tls_profiles["dc1"].cert.as_deref(),
2636            Some(std::path::Path::new("/etc/dynomite/dc1.pem"))
2637        );
2638        assert!(p.peer_tls_profiles["dc2"].ca.is_none());
2639        let dumped = serde_yaml::to_string(&p).unwrap();
2640        let p2: ConfPool = serde_yaml::from_str(&dumped).unwrap();
2641        assert_eq!(p2.peer_tls_profiles, p.peer_tls_profiles);
2642    }
2643
2644    #[test]
2645    fn transport_default_is_tcp_after_finalize() {
2646        let mut p = pool();
2647        p.apply_defaults();
2648        assert_eq!(p.transport, Some(Transport::Tcp));
2649        assert!(p.validate("p").is_ok());
2650    }
2651
2652    #[test]
2653    fn transport_quic_yaml_round_trip() {
2654        let yaml = r"
2655listen: 127.0.0.1:8102
2656servers:
2657- 127.0.0.1:6379:1
2658tokens: '0'
2659transport: quic
2660quic_cert_file: /tmp/test.crt
2661quic_key_file: /tmp/test.key
2662";
2663        let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
2664        assert_eq!(p.transport, Some(Transport::Quic));
2665        assert_eq!(
2666            p.quic_cert_file.as_deref(),
2667            Some(std::path::Path::new("/tmp/test.crt"))
2668        );
2669        assert_eq!(
2670            p.quic_key_file.as_deref(),
2671            Some(std::path::Path::new("/tmp/test.key"))
2672        );
2673        let dumped = serde_yaml::to_string(&p).unwrap();
2674        let p2: ConfPool = serde_yaml::from_str(&dumped).unwrap();
2675        assert_eq!(p2.transport, p.transport);
2676        assert_eq!(p2.quic_cert_file, p.quic_cert_file);
2677        assert_eq!(p2.quic_key_file, p.quic_key_file);
2678    }
2679
2680    #[test]
2681    fn transport_quic_requires_cert_and_key() {
2682        let mut p = pool();
2683        p.transport = Some(Transport::Quic);
2684        p.apply_defaults();
2685        let err = p.validate("p").unwrap_err();
2686        assert!(matches!(
2687            err,
2688            ConfError::BadServer {
2689                field: "quic_cert_file",
2690                ..
2691            }
2692        ));
2693        p.quic_cert_file = Some(std::path::PathBuf::from("/tmp/c.pem"));
2694        let err = p.validate("p").unwrap_err();
2695        assert!(matches!(
2696            err,
2697            ConfError::BadServer {
2698                field: "quic_key_file",
2699                ..
2700            }
2701        ));
2702        p.quic_key_file = Some(std::path::PathBuf::from("/tmp/k.pem"));
2703        assert!(p.validate("p").is_ok());
2704    }
2705
2706    #[test]
2707    fn transport_tcp_ignores_quic_files() {
2708        let mut p = pool();
2709        p.transport = Some(Transport::Tcp);
2710        // Setting cert / key under TCP is tolerated; the
2711        // listener is plain TCP so the QUIC knobs are simply
2712        // unused.
2713        p.quic_cert_file = Some(std::path::PathBuf::from("/tmp/c.pem"));
2714        p.apply_defaults();
2715        assert!(p.validate("p").is_ok());
2716    }
2717}