1use 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
22pub mod defaults {
25 pub const TIMEOUT_MS: i64 = 5_000;
27 pub const LISTEN_BACKLOG: i64 = 512;
29 pub const CLIENT_CONNECTIONS: i64 = 0;
31 pub const DATA_STORE: i64 = 0;
33 pub const PRECONNECT: bool = false;
39 pub const AUTO_EJECT_HOSTS: bool = true;
41 pub const SERVER_RETRY_TIMEOUT_MS: i64 = 10 * 1000;
43 pub const SERVER_FAILURE_LIMIT: i64 = 3;
45 pub const DYN_READ_TIMEOUT_MS: i64 = 10_000;
47 pub const DYN_WRITE_TIMEOUT_MS: i64 = 10_000;
49 pub const DYN_CONNECTIONS: i64 = 100;
51 pub const GOS_INTERVAL_MS: i64 = 30_000;
53 pub const ENABLE_HINTED_HANDOFF: bool = false;
56 pub const HINT_TTL_SECONDS: u64 = 86_400;
59 pub const HINT_STORE_MAX_BYTES: u64 = 64 * 1024 * 1024;
62 pub const HINT_DRAIN_INTERVAL_MS: u64 = 30_000;
65 pub const CONN_MSG_RATE: u32 = 50_000;
67 pub const STATS_INTERVAL_MS: i64 = 30 * 1000;
69 pub const STATS_PNAME: &str = "0.0.0.0:22222";
71 pub const DATASTORE_CONNECTIONS: u8 = 1;
73 pub const LOCAL_PEER_CONNECTIONS: u8 = 1;
75 pub const REMOTE_PEER_CONNECTIONS: u8 = 1;
77 pub const RACK: &str = "localrack";
79 pub const DC: &str = "localdc";
81 pub const SECURE_SERVER_OPTION: &str = "none";
83 pub const CONSISTENCY: &str = "DC_ONE";
85 pub const SEED_PROVIDER: &str = "simple_provider";
87 pub const ENV: &str = "aws";
89 pub const PEM_KEY_FILE: &str = "conf/dynomite.pem";
91 pub const RECON_KEY_FILE: &str = "conf/recon_key.pem";
93 pub const RECON_IV_FILE: &str = "conf/recon_iv.pem";
95 pub const RECON_INTERVAL_SECONDS: u64 = 300;
99 pub const MBUF_MIN_SIZE: i64 = 512;
101 pub const MBUF_MAX_SIZE: i64 = 512_000;
103 pub const ALLOC_MSGS_MIN: i64 = 100_000;
105 pub const ALLOC_MSGS_MAX: i64 = 1_000_000;
107}
108
109#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
122#[serde(transparent)]
123pub struct Servers(pub(crate) Vec<ConfServer>);
124
125impl Servers {
126 pub fn from_vec(v: Vec<ConfServer>) -> Self {
137 Self(v)
138 }
139}
140
141impl Servers {
142 pub fn entries(&self) -> &[ConfServer] {
152 &self.0
153 }
154 pub fn len(&self) -> usize {
163 self.0.len()
164 }
165 pub fn is_empty(&self) -> bool {
174 self.0.is_empty()
175 }
176 pub fn datastore(&self) -> Option<&ConfServer> {
187 self.0.first()
188 }
189}
190
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
204#[serde(deny_unknown_fields, default)]
205pub struct ConfPool {
206 pub listen: Option<ConfListen>,
208 pub dyn_listen: Option<ConfListen>,
210 pub stats_listen: Option<ConfListen>,
212
213 pub hash: Option<HashType>,
215 pub hash_tag: Option<String>,
217
218 #[serde(default)]
224 pub distribution: Option<Distribution>,
225 #[serde(default)]
232 pub distribution_shadow: Option<Distribution>,
233 #[serde(default)]
235 pub server_connections: Option<i64>,
236
237 pub timeout: Option<i64>,
239 pub backlog: Option<i64>,
241 pub client_connections: Option<i64>,
243 #[serde(default, deserialize_with = "deserialize_data_store")]
249 pub data_store: Option<i64>,
250 #[serde(default)]
256 pub noxu_path: Option<PathBuf>,
257 #[serde(default)]
268 pub search_index_dir: Option<PathBuf>,
269 pub preconnect: Option<bool>,
271 #[serde(default)]
278 pub redis_requirepass: Option<String>,
279 pub auto_eject_hosts: Option<bool>,
281 pub server_retry_timeout: Option<i64>,
283 pub server_failure_limit: Option<i64>,
285
286 pub servers: Option<Servers>,
288
289 pub dyn_read_timeout: Option<i64>,
291 pub dyn_write_timeout: Option<i64>,
293 pub dyn_seed_provider: Option<String>,
295 pub dyn_seeds: Option<Vec<ConfDynSeed>>,
297 pub dyn_port: Option<i64>,
299 pub dyn_connections: Option<i64>,
301 pub rack: Option<String>,
303 pub tokens: Option<TokenList>,
305 pub gos_interval: Option<i64>,
307 pub secure_server_option: Option<String>,
309 pub pem_key_file: Option<String>,
311 pub recon_key_file: Option<String>,
313 pub recon_iv_file: Option<String>,
315 #[serde(default)]
324 pub recon_interval_seconds: Option<u64>,
325 pub datacenter: Option<String>,
327 pub env: Option<String>,
329 pub conn_msg_rate: Option<u32>,
331 pub read_consistency: Option<String>,
333 pub write_consistency: Option<String>,
335 pub stats_interval: Option<i64>,
337 pub enable_gossip: Option<bool>,
339 #[serde(default)]
345 pub membership: Option<Membership>,
346 #[serde(default)]
353 pub peer_tls_cert: Option<PathBuf>,
354 #[serde(default)]
357 pub peer_tls_key: Option<PathBuf>,
358 #[serde(default)]
366 pub peer_tls_ca: Option<PathBuf>,
367 #[serde(default)]
387 pub peer_tls_profiles: BTreeMap<String, ConfTlsProfile>,
388 pub mbuf_size: Option<i64>,
390 pub max_msgs: Option<i64>,
392 pub datastore_connections: Option<u8>,
394 pub local_peer_connections: Option<u8>,
396 pub remote_peer_connections: Option<u8>,
398 pub read_repairs_enabled: Option<bool>,
400 #[serde(default)]
412 pub enable_hinted_handoff: Option<bool>,
413 #[serde(default)]
418 pub hint_ttl_seconds: Option<u64>,
419 #[serde(default)]
427 pub hint_store_max_bytes: Option<u64>,
428 #[serde(default)]
432 pub hint_drain_interval_ms: Option<u64>,
433 #[serde(default)]
443 pub hint_dir: Option<PathBuf>,
444 pub log_format: Option<String>,
452
453 #[serde(default)]
459 pub observability: Option<ObservabilityConfig>,
460
461 #[serde(default)]
475 pub bucket_types: Vec<ConfBucketType>,
476
477 #[serde(default)]
483 pub default_bucket_type: Option<String>,
484
485 #[serde(default)]
498 pub riak: Option<ConfRiak>,
499
500 #[serde(default)]
513 pub transport: Option<Transport>,
514
515 #[serde(default)]
520 pub quic_cert_file: Option<PathBuf>,
521
522 #[serde(default)]
527 pub quic_key_file: Option<PathBuf>,
528}
529
530#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
557#[serde(deny_unknown_fields, default)]
558pub struct ConfRiak {
559 pub pbc_listen: Option<String>,
563 pub http_listen: Option<String>,
567 pub quic_listen: Option<String>,
583 pub aae_enabled: Option<bool>,
586 pub aae_full_sweep_interval_seconds: Option<u64>,
590 pub aae_segment_interval_seconds: Option<u64>,
594 #[serde(default)]
601 pub tls_cert: Option<PathBuf>,
602 #[serde(default)]
605 pub tls_key: Option<PathBuf>,
606 #[serde(default)]
612 pub tls_ca: Option<PathBuf>,
613 #[serde(default)]
629 pub wasm_modules: Option<Vec<ConfRiakWasmModule>>,
630}
631
632#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
647#[serde(deny_unknown_fields)]
648pub struct ConfRiakWasmModule {
649 pub id: String,
652 pub path: PathBuf,
655}
656
657#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
681#[serde(deny_unknown_fields, default)]
682pub struct ConfTlsProfile {
683 pub cert: Option<PathBuf>,
685 pub key: Option<PathBuf>,
687 pub ca: Option<PathBuf>,
695}
696
697impl ConfTlsProfile {
698 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 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 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
876fn 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 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#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
971#[serde(deny_unknown_fields)]
972pub struct ConfBucketType {
973 pub name: String,
978 pub read_consistency: String,
984 pub write_consistency: String,
987 #[serde(default)]
992 pub n_val: u8,
993}
994
995impl ConfBucketType {
996 pub fn read_level(&self) -> Result<ConsistencyLevel, ConfError> {
1011 ConsistencyLevel::parse("read_consistency", &self.read_consistency)
1012 }
1013
1014 pub fn write_level(&self) -> Result<ConsistencyLevel, ConfError> {
1029 ConsistencyLevel::parse("write_consistency", &self.write_consistency)
1030 }
1031}
1032
1033#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1052#[serde(deny_unknown_fields, default)]
1053pub struct ObservabilityConfig {
1054 pub otlp_traces_endpoint: Option<String>,
1059 pub otlp_logs_endpoint: Option<String>,
1065 pub service_name: Option<String>,
1068 pub traces_sampling: Option<f64>,
1071}
1072
1073impl ConfPool {
1074 #[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 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 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 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 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 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 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 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 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 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
1628fn 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 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 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 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 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 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 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 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 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 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 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 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}