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