dynomite/conf/mod.rs
1//! YAML configuration: schema, parsing, defaulting, validation.
2//!
3//! The top-level YAML document is a single-key mapping from a pool name
4//! to a [`ConfPool`]. [`Config`] wraps both. The typical lifecycle is:
5//!
6//! 1. [`Config::parse_str`] (or [`Config::parse_file`]) - parse YAML and
7//! apply structural checks.
8//! 2. [`Config::finalize`] - apply defaults to fields that were left
9//! unset.
10//! 3. [`Config::validate`] - run the full set of cross-field checks.
11//!
12//! [`Config::test_conf`] is the convenience used by the `-t` flag of the
13//! server binary and runs `finalize` + `validate` and returns a short
14//! status string.
15//!
16//! # Examples
17//!
18//! ```
19//! use dynomite::conf::Config;
20//!
21//! let yaml = r#"
22//! dyn_o_mite:
23//! listen: 127.0.0.1:8102
24//! dyn_listen: 127.0.0.1:8101
25//! tokens: '101134286'
26//! servers:
27//! - 127.0.0.1:22122:1
28//! data_store: 0
29//! mbuf_size: 16384
30//! max_msgs: 300000
31//! "#;
32//!
33//! let mut cfg = Config::parse_str(yaml).unwrap();
34//! cfg.finalize();
35//! cfg.validate().unwrap();
36//! assert_eq!(cfg.pool_name(), "dyn_o_mite");
37//! ```
38
39mod endpoint;
40mod enums;
41mod error;
42mod pool;
43mod server;
44mod tokens;
45
46pub use endpoint::{ConfListen, EndpointKind};
47pub use enums::{ConsistencyLevel, DataStore, Distribution, HashType, SecureServerOption};
48pub use error::ConfError;
49pub use pool::{
50 ConfBucketType, ConfPool, ConfRiak, ConfRiakWasmModule, ConfTlsProfile, ObservabilityConfig,
51 Servers,
52};
53pub use server::{ConfDynSeed, ConfServer};
54pub use tokens::{TokenComponent, TokenList};
55
56use std::collections::BTreeMap;
57use std::path::Path;
58use std::sync::atomic::{AtomicBool, Ordering};
59
60/// Process-wide flag set by an embedder to declare that the
61/// `data_store: noxu` configuration value is supported by this
62/// build.
63///
64/// The engine ships with the flag off; the `dynomited` binary
65/// flips it to `true` at startup when compiled with
66/// `--features riak` (which is the gate that pulls in the
67/// `dyniak::NoxuDatastore` type). Pool validation rejects
68/// `data_store: noxu` with [`ConfError::BadNoxuConfig`] when
69/// the flag is `false`.
70///
71/// # Examples
72///
73/// ```
74/// use dynomite::conf::{is_noxu_supported, set_noxu_supported};
75/// let prev = is_noxu_supported();
76/// set_noxu_supported(true);
77/// assert!(is_noxu_supported());
78/// set_noxu_supported(prev);
79/// ```
80static NOXU_SUPPORTED: AtomicBool = AtomicBool::new(false);
81
82/// Set the process-wide "noxu data_store is supported" flag.
83///
84/// Idempotent. See [`NOXU_SUPPORTED`] for the contract.
85pub fn set_noxu_supported(on: bool) {
86 NOXU_SUPPORTED.store(on, Ordering::SeqCst);
87}
88
89/// Read the process-wide "noxu data_store is supported" flag.
90#[must_use]
91pub fn is_noxu_supported() -> bool {
92 NOXU_SUPPORTED.load(Ordering::SeqCst)
93}
94
95/// Top-level configuration value: a single named [`ConfPool`].
96///
97/// The YAML document mirrors the C reference: a top-level mapping with
98/// exactly one key, the pool name, whose value is the pool body.
99#[derive(Debug, Clone)]
100pub struct Config {
101 pool_name: String,
102 pool: ConfPool,
103}
104
105impl Config {
106 /// Parse a YAML configuration document from a string.
107 ///
108 /// Performs structural validation (exactly one pool, no unknown
109 /// keys) but does not apply defaults. Call [`Config::finalize`]
110 /// before [`Config::validate`] to fully prepare the config.
111 ///
112 /// # Examples
113 ///
114 /// ```
115 /// use dynomite::conf::Config;
116 /// let yaml = "p:\n listen: 127.0.0.1:1\n dyn_listen: 127.0.0.1:2\n tokens: '1'\n servers:\n - 127.0.0.1:3:1\n data_store: 0\n";
117 /// let cfg = Config::parse_str(yaml).unwrap();
118 /// assert_eq!(cfg.pool_name(), "p");
119 /// assert!(Config::parse_str("").is_err());
120 /// ```
121 pub fn parse_str(input: &str) -> Result<Self, ConfError> {
122 let raw: BTreeMap<String, ConfPool> =
123 serde_yaml::from_str(input).map_err(|e| ConfError::from_yaml(&e))?;
124 if raw.is_empty() {
125 return Err(ConfError::EmptyDocument);
126 }
127 if raw.len() != 1 {
128 return Err(ConfError::TooManyPools(raw.len()));
129 }
130 let (pool_name, pool) = raw
131 .into_iter()
132 .next()
133 .expect("invariant: raw.len() == 1, checked above");
134 if pool_name.is_empty() {
135 return Err(ConfError::EmptyPoolName);
136 }
137 Ok(Self { pool_name, pool })
138 }
139
140 /// Parse a YAML configuration document from a filesystem path.
141 ///
142 /// # Examples
143 ///
144 /// ```
145 /// use std::io::Write;
146 /// use dynomite::conf::Config;
147 /// let mut f = tempfile::NamedTempFile::new().unwrap();
148 /// writeln!(f, "p:\n listen: 127.0.0.1:1\n dyn_listen: 127.0.0.1:2\n tokens: '1'\n servers:\n - 127.0.0.1:3:1\n data_store: 0\n").unwrap();
149 /// let cfg = Config::parse_file(f.path()).unwrap();
150 /// assert_eq!(cfg.pool_name(), "p");
151 /// ```
152 pub fn parse_file(path: &Path) -> Result<Self, ConfError> {
153 let bytes = std::fs::read_to_string(path).map_err(|e| ConfError::Io {
154 path: path.to_path_buf(),
155 source: e,
156 })?;
157 Self::parse_str(&bytes)
158 }
159
160 /// The configured pool name (the single top-level YAML key).
161 ///
162 /// # Examples
163 ///
164 /// ```
165 /// use dynomite::conf::Config;
166 /// let cfg = Config::parse_str("my_pool:\n listen: 127.0.0.1:1\n dyn_listen: 127.0.0.1:2\n tokens: '1'\n servers:\n - 127.0.0.1:3:1\n data_store: 0\n").unwrap();
167 /// assert_eq!(cfg.pool_name(), "my_pool");
168 /// ```
169 pub fn pool_name(&self) -> &str {
170 &self.pool_name
171 }
172
173 /// Borrow the inner [`ConfPool`].
174 ///
175 /// # Examples
176 ///
177 /// ```
178 /// use dynomite::conf::Config;
179 /// let cfg = Config::parse_str("p:\n listen: 127.0.0.1:8102\n dyn_listen: 127.0.0.1:2\n tokens: '1'\n servers:\n - 127.0.0.1:3:1\n data_store: 0\n").unwrap();
180 /// assert_eq!(cfg.pool().listen.as_ref().unwrap().port(), 8102);
181 /// ```
182 pub fn pool(&self) -> &ConfPool {
183 &self.pool
184 }
185
186 /// Mutably borrow the inner [`ConfPool`].
187 ///
188 /// # Examples
189 ///
190 /// ```
191 /// use dynomite::conf::Config;
192 /// let mut cfg = Config::parse_str("p:\n listen: 127.0.0.1:1\n dyn_listen: 127.0.0.1:2\n tokens: '1'\n servers:\n - 127.0.0.1:3:1\n data_store: 0\n").unwrap();
193 /// cfg.pool_mut().preconnect = Some(true);
194 /// assert_eq!(cfg.pool().preconnect, Some(true));
195 /// ```
196 pub fn pool_mut(&mut self) -> &mut ConfPool {
197 &mut self.pool
198 }
199
200 /// Apply default values to any field left unset by the YAML.
201 ///
202 /// # Examples
203 ///
204 /// ```
205 /// use dynomite::conf::Config;
206 /// let mut cfg = Config::parse_str("p:\n listen: 127.0.0.1:1\n dyn_listen: 127.0.0.1:2\n tokens: '1'\n servers:\n - 127.0.0.1:3:1\n data_store: 0\n").unwrap();
207 /// assert!(cfg.pool().rack.is_none());
208 /// cfg.finalize();
209 /// assert!(cfg.pool().rack.is_some());
210 /// ```
211 pub fn finalize(&mut self) {
212 self.pool.apply_defaults();
213 }
214
215 /// Run the full validation pass.
216 ///
217 /// # Examples
218 ///
219 /// ```
220 /// use dynomite::conf::Config;
221 /// let mut cfg = Config::parse_str("p:\n listen: 127.0.0.1:1\n dyn_listen: 127.0.0.1:2\n tokens: '1'\n servers:\n - 127.0.0.1:3:1\n data_store: 0\n").unwrap();
222 /// cfg.finalize();
223 /// cfg.validate().unwrap();
224 /// ```
225 pub fn validate(&self) -> Result<(), ConfError> {
226 self.pool.validate(&self.pool_name)
227 }
228
229 /// Equivalent of `dynomite -t -c <file>`: finalize, validate, and
230 /// produce a short status string.
231 ///
232 /// # Examples
233 ///
234 /// ```
235 /// use dynomite::conf::Config;
236 /// let cfg = Config::parse_str("p:\n listen: 127.0.0.1:1\n dyn_listen: 127.0.0.1:2\n tokens: '1'\n servers:\n - 127.0.0.1:3:1\n data_store: 0\n").unwrap();
237 /// assert!(cfg.test_conf().unwrap().contains("is valid"));
238 /// ```
239 pub fn test_conf(&self) -> Result<String, ConfError> {
240 let mut owned = self.clone();
241 owned.finalize();
242 owned.validate()?;
243 Ok(format!(
244 "configuration file with pool '{}' is valid",
245 owned.pool_name
246 ))
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 const MINIMAL: &str = r"
255dyn_o_mite:
256 listen: 127.0.0.1:8102
257 dyn_listen: 127.0.0.1:8101
258 tokens: '101134286'
259 servers:
260 - 127.0.0.1:22122:1
261 data_store: 0
262";
263
264 #[test]
265 fn parse_minimal() {
266 let cfg = Config::parse_str(MINIMAL).unwrap();
267 assert_eq!(cfg.pool_name(), "dyn_o_mite");
268 assert_eq!(cfg.pool().listen.as_ref().unwrap().port(), 8102);
269 }
270
271 #[test]
272 fn finalize_sets_defaults() {
273 let mut cfg = Config::parse_str(MINIMAL).unwrap();
274 cfg.finalize();
275 assert_eq!(cfg.pool().rack.as_deref(), Some("localrack"));
276 assert_eq!(cfg.pool().datacenter.as_deref(), Some("localdc"));
277 assert_eq!(cfg.pool().timeout, Some(5000));
278 }
279
280 #[test]
281 fn validate_minimal() {
282 let mut cfg = Config::parse_str(MINIMAL).unwrap();
283 cfg.finalize();
284 cfg.validate().unwrap();
285 }
286
287 #[test]
288 fn empty_document_rejected() {
289 let err = Config::parse_str("").unwrap_err();
290 assert!(matches!(
291 err,
292 ConfError::Yaml { .. } | ConfError::EmptyDocument
293 ));
294 }
295
296 #[test]
297 fn too_many_pools_rejected() {
298 let yaml = "a:\n listen: 1.2.3.4:80\nb:\n listen: 1.2.3.4:81\n";
299 let err = Config::parse_str(yaml).unwrap_err();
300 assert!(matches!(err, ConfError::TooManyPools(2)));
301 }
302
303 #[test]
304 fn unknown_key_rejected() {
305 let yaml = "p:\n listen: 127.0.0.1:1\n bogus_key: 42\n";
306 let err = Config::parse_str(yaml).unwrap_err();
307 match err {
308 ConfError::UnknownKey { name } => assert_eq!(name, "bogus_key"),
309 other => panic!("unexpected error: {other:?}"),
310 }
311 }
312
313 #[test]
314 fn test_conf_reports_pool_name() {
315 let cfg = Config::parse_str(MINIMAL).unwrap();
316 let report = cfg.test_conf().unwrap();
317 assert!(report.contains("dyn_o_mite"));
318 }
319}