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, 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)]
346 pub peer_tls_cert: Option<PathBuf>,
347 #[serde(default)]
350 pub peer_tls_key: Option<PathBuf>,
351 #[serde(default)]
359 pub peer_tls_ca: Option<PathBuf>,
360 #[serde(default)]
380 pub peer_tls_profiles: BTreeMap<String, ConfTlsProfile>,
381 pub mbuf_size: Option<i64>,
383 pub max_msgs: Option<i64>,
385 pub datastore_connections: Option<u8>,
387 pub local_peer_connections: Option<u8>,
389 pub remote_peer_connections: Option<u8>,
391 pub read_repairs_enabled: Option<bool>,
393 #[serde(default)]
405 pub enable_hinted_handoff: Option<bool>,
406 #[serde(default)]
411 pub hint_ttl_seconds: Option<u64>,
412 #[serde(default)]
420 pub hint_store_max_bytes: Option<u64>,
421 #[serde(default)]
425 pub hint_drain_interval_ms: Option<u64>,
426 #[serde(default)]
436 pub hint_dir: Option<PathBuf>,
437 pub log_format: Option<String>,
445
446 #[serde(default)]
452 pub observability: Option<ObservabilityConfig>,
453
454 #[serde(default)]
468 pub bucket_types: Vec<ConfBucketType>,
469
470 #[serde(default)]
476 pub default_bucket_type: Option<String>,
477
478 #[serde(default)]
491 pub riak: Option<ConfRiak>,
492
493 #[serde(default)]
506 pub transport: Option<Transport>,
507
508 #[serde(default)]
513 pub quic_cert_file: Option<PathBuf>,
514
515 #[serde(default)]
520 pub quic_key_file: Option<PathBuf>,
521}
522
523#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
550#[serde(deny_unknown_fields, default)]
551pub struct ConfRiak {
552 pub pbc_listen: Option<String>,
556 pub http_listen: Option<String>,
560 pub quic_listen: Option<String>,
576 pub aae_enabled: Option<bool>,
579 pub aae_full_sweep_interval_seconds: Option<u64>,
583 pub aae_segment_interval_seconds: Option<u64>,
587 #[serde(default)]
594 pub tls_cert: Option<PathBuf>,
595 #[serde(default)]
598 pub tls_key: Option<PathBuf>,
599 #[serde(default)]
605 pub tls_ca: Option<PathBuf>,
606 #[serde(default)]
622 pub wasm_modules: Option<Vec<ConfRiakWasmModule>>,
623}
624
625#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
640#[serde(deny_unknown_fields)]
641pub struct ConfRiakWasmModule {
642 pub id: String,
645 pub path: PathBuf,
648}
649
650#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
674#[serde(deny_unknown_fields, default)]
675pub struct ConfTlsProfile {
676 pub cert: Option<PathBuf>,
678 pub key: Option<PathBuf>,
680 pub ca: Option<PathBuf>,
688}
689
690impl ConfTlsProfile {
691 pub fn validate(&self, dc: &str) -> Result<(), ConfError> {
717 match (self.cert.as_deref(), self.key.as_deref()) {
718 (Some(_), Some(_)) | (None, None) => {}
719 (Some(c), None) => {
720 return Err(ConfError::BadServer {
721 field: "peer_tls_profiles.cert",
722 value: c.display().to_string(),
723 reason: format!(
724 "peer_tls_profiles[{dc}].cert is set but .key is not; both must be set together"
725 ),
726 });
727 }
728 (None, Some(k)) => {
729 return Err(ConfError::BadServer {
730 field: "peer_tls_profiles.key",
731 value: k.display().to_string(),
732 reason: format!(
733 "peer_tls_profiles[{dc}].key is set but .cert is not; both must be set together"
734 ),
735 });
736 }
737 }
738 if self.ca.is_some() && self.cert.is_none() {
739 return Err(ConfError::BadServer {
740 field: "peer_tls_profiles.ca",
741 value: self
742 .ca
743 .as_ref()
744 .map_or_else(String::new, |p| p.display().to_string()),
745 reason: format!(
746 "peer_tls_profiles[{dc}].ca requires .cert and .key to also be set"
747 ),
748 });
749 }
750 Ok(())
751 }
752}
753
754impl ConfRiak {
755 pub fn validate(&self) -> Result<(), ConfError> {
774 if let Some(addr) = self.pbc_listen.as_deref() {
775 validate_riak_addr("pbc_listen", addr)?;
776 }
777 if let Some(addr) = self.http_listen.as_deref() {
778 validate_riak_addr("http_listen", addr)?;
779 }
780 if let Some(addr) = self.quic_listen.as_deref() {
781 validate_riak_addr("quic_listen", addr)?;
782 if self.tls_cert.is_none() || self.tls_key.is_none() {
786 return Err(ConfError::BadServer {
787 field: "quic_listen",
788 value: addr.to_string(),
789 reason: "quic_listen requires tls_cert and tls_key to also be set".into(),
790 });
791 }
792 }
793 if let Some(n) = self.aae_full_sweep_interval_seconds {
794 if n == 0 {
795 return Err(ConfError::BadServer {
796 field: "aae_full_sweep_interval_seconds",
797 value: n.to_string(),
798 reason: "must be > 0".into(),
799 });
800 }
801 }
802 if let Some(n) = self.aae_segment_interval_seconds {
803 if n == 0 {
804 return Err(ConfError::BadServer {
805 field: "aae_segment_interval_seconds",
806 value: n.to_string(),
807 reason: "must be > 0".into(),
808 });
809 }
810 }
811 if let (Some(seg), Some(full)) = (
812 self.aae_segment_interval_seconds,
813 self.aae_full_sweep_interval_seconds,
814 ) {
815 if seg > full {
816 return Err(ConfError::BadServer {
817 field: "aae_segment_interval_seconds",
818 value: seg.to_string(),
819 reason: format!("must be <= aae_full_sweep_interval_seconds ({full})"),
820 });
821 }
822 }
823 validate_tls_pair(
824 "tls_cert",
825 "tls_key",
826 self.tls_cert.as_deref(),
827 self.tls_key.as_deref(),
828 )?;
829 if self.tls_ca.is_some() && self.tls_cert.is_none() {
830 return Err(ConfError::BadServer {
831 field: "tls_ca",
832 value: self
833 .tls_ca
834 .as_ref()
835 .map_or_else(String::new, |p| p.display().to_string()),
836 reason: "requires tls_cert and tls_key to also be set".into(),
837 });
838 }
839 if let Some(modules) = self.wasm_modules.as_deref() {
840 let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
841 for m in modules {
842 if m.id.is_empty() {
843 return Err(ConfError::BadServer {
844 field: "wasm_modules.id",
845 value: String::new(),
846 reason: "wasm module id must not be empty".into(),
847 });
848 }
849 if !seen.insert(m.id.as_str()) {
850 return Err(ConfError::BadServer {
851 field: "wasm_modules.id",
852 value: m.id.clone(),
853 reason: "wasm module ids must be unique".into(),
854 });
855 }
856 if !m.path.is_file() {
857 return Err(ConfError::BadServer {
858 field: "wasm_modules.path",
859 value: m.path.display().to_string(),
860 reason: format!("wasm module file not found for id '{}'", m.id),
861 });
862 }
863 }
864 }
865 Ok(())
866 }
867}
868
869fn validate_tls_pair(
873 cert_field: &'static str,
874 key_field: &'static str,
875 cert: Option<&std::path::Path>,
876 key: Option<&std::path::Path>,
877) -> Result<(), ConfError> {
878 match (cert, key) {
879 (Some(_), Some(_)) | (None, None) => Ok(()),
880 (Some(c), None) => Err(ConfError::BadServer {
881 field: cert_field,
882 value: c.display().to_string(),
883 reason: format!(
884 "{cert_field} is set but {key_field} is not; both must be set together"
885 ),
886 }),
887 (None, Some(k)) => Err(ConfError::BadServer {
888 field: key_field,
889 value: k.display().to_string(),
890 reason: format!(
891 "{key_field} is set but {cert_field} is not; both must be set together"
892 ),
893 }),
894 }
895}
896
897fn validate_riak_addr(field: &'static str, value: &str) -> Result<(), ConfError> {
898 use std::net::ToSocketAddrs;
899 if value.is_empty() {
900 return Err(ConfError::BadServer {
901 field,
902 value: value.to_string(),
903 reason: "riak listen address must not be empty".into(),
904 });
905 }
906 if value.parse::<std::net::SocketAddr>().is_ok() {
910 return Ok(());
911 }
912 match value.to_socket_addrs() {
913 Ok(mut iter) => {
914 if iter.next().is_some() {
915 Ok(())
916 } else {
917 Err(ConfError::BadServer {
918 field,
919 value: value.to_string(),
920 reason: "resolved to no addresses".into(),
921 })
922 }
923 }
924 Err(e) => Err(ConfError::BadServer {
925 field,
926 value: value.to_string(),
927 reason: format!("could not resolve: {e}"),
928 }),
929 }
930}
931
932#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
964#[serde(deny_unknown_fields)]
965pub struct ConfBucketType {
966 pub name: String,
971 pub read_consistency: String,
977 pub write_consistency: String,
980 #[serde(default)]
985 pub n_val: u8,
986}
987
988impl ConfBucketType {
989 pub fn read_level(&self) -> Result<ConsistencyLevel, ConfError> {
1004 ConsistencyLevel::parse("read_consistency", &self.read_consistency)
1005 }
1006
1007 pub fn write_level(&self) -> Result<ConsistencyLevel, ConfError> {
1022 ConsistencyLevel::parse("write_consistency", &self.write_consistency)
1023 }
1024}
1025
1026#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1045#[serde(deny_unknown_fields, default)]
1046pub struct ObservabilityConfig {
1047 pub otlp_traces_endpoint: Option<String>,
1052 pub otlp_logs_endpoint: Option<String>,
1058 pub service_name: Option<String>,
1061 pub traces_sampling: Option<f64>,
1064}
1065
1066impl ConfPool {
1067 #[must_use]
1087 pub fn resolved_distribution(&self) -> Distribution {
1088 match self.distribution {
1089 None | Some(Distribution::Vnode) => Distribution::Vnode,
1090 Some(Distribution::RandomSlicing) => Distribution::RandomSlicing,
1091 Some(other) => {
1092 tracing::warn!(
1093 target: "dynomite::conf",
1094 distribution = other.as_str(),
1095 "distribution mode '{}' is a legacy alias and resolves to 'vnode'; \
1096 update the YAML to either 'vnode' or 'random_slicing'",
1097 other
1098 );
1099 Distribution::Vnode
1100 }
1101 }
1102 }
1103}
1104
1105impl ConfPool {
1106 pub fn apply_defaults(&mut self) {
1118 if self.dyn_seed_provider.is_none() {
1119 self.dyn_seed_provider = Some(defaults::SEED_PROVIDER.to_string());
1120 }
1121 if self.hash.is_none() {
1122 self.hash = Some(HashType::Murmur);
1123 }
1124 if self.timeout.is_none() {
1125 self.timeout = Some(defaults::TIMEOUT_MS);
1126 }
1127 if self.backlog.is_none() {
1128 self.backlog = Some(defaults::LISTEN_BACKLOG);
1129 }
1130 self.client_connections = Some(defaults::CLIENT_CONNECTIONS);
1133 if self.data_store.is_none() {
1134 self.data_store = Some(defaults::DATA_STORE);
1135 }
1136 if self.preconnect.is_none() {
1137 self.preconnect = Some(defaults::PRECONNECT);
1138 }
1139 if self.auto_eject_hosts.is_none() {
1140 self.auto_eject_hosts = Some(defaults::AUTO_EJECT_HOSTS);
1141 }
1142 if self.server_retry_timeout.is_none() {
1143 self.server_retry_timeout = Some(defaults::SERVER_RETRY_TIMEOUT_MS);
1144 }
1145 if self.server_failure_limit.is_none() {
1146 self.server_failure_limit = Some(defaults::SERVER_FAILURE_LIMIT);
1147 }
1148 if self.dyn_read_timeout.is_none() {
1149 self.dyn_read_timeout = Some(defaults::DYN_READ_TIMEOUT_MS);
1150 }
1151 if self.dyn_write_timeout.is_none() {
1152 self.dyn_write_timeout = Some(defaults::DYN_WRITE_TIMEOUT_MS);
1153 }
1154 if self.dyn_connections.is_none() {
1155 self.dyn_connections = Some(defaults::DYN_CONNECTIONS);
1156 }
1157 if self.gos_interval.is_none() {
1158 self.gos_interval = Some(defaults::GOS_INTERVAL_MS);
1159 }
1160 if self.conn_msg_rate.is_none() {
1161 self.conn_msg_rate = Some(defaults::CONN_MSG_RATE);
1162 }
1163 if self.rack.is_none() {
1164 self.rack = Some(defaults::RACK.to_string());
1165 }
1166 if self.datacenter.is_none() {
1167 self.datacenter = Some(defaults::DC.to_string());
1168 }
1169 if self.secure_server_option.is_none() {
1170 self.secure_server_option = Some(defaults::SECURE_SERVER_OPTION.to_string());
1171 }
1172 if self.read_consistency.is_none() {
1173 self.read_consistency = Some(defaults::CONSISTENCY.to_string());
1174 }
1175 if self.write_consistency.is_none() {
1176 self.write_consistency = Some(defaults::CONSISTENCY.to_string());
1177 }
1178 if self.stats_interval.is_none() {
1179 self.stats_interval = Some(defaults::STATS_INTERVAL_MS);
1180 }
1181 if self.stats_listen.is_none() {
1182 self.stats_listen = Some(
1184 ConfListen::parse("stats_listen", defaults::STATS_PNAME)
1185 .expect("invariant: STATS_PNAME constant is valid"),
1186 );
1187 }
1188 if self.env.is_none() {
1189 self.env = Some(defaults::ENV.to_string());
1190 }
1191 if self.pem_key_file.is_none() {
1192 self.pem_key_file = Some(defaults::PEM_KEY_FILE.to_string());
1193 }
1194 if self.recon_key_file.is_none() {
1195 self.recon_key_file = Some(defaults::RECON_KEY_FILE.to_string());
1196 }
1197 if self.recon_iv_file.is_none() {
1198 self.recon_iv_file = Some(defaults::RECON_IV_FILE.to_string());
1199 }
1200 if self.recon_interval_seconds.is_none() {
1201 self.recon_interval_seconds = Some(defaults::RECON_INTERVAL_SECONDS);
1202 }
1203 if self.datastore_connections.is_none() {
1204 self.datastore_connections = Some(defaults::DATASTORE_CONNECTIONS);
1205 }
1206 if self.local_peer_connections.is_none() {
1207 self.local_peer_connections = Some(defaults::LOCAL_PEER_CONNECTIONS);
1208 }
1209 if self.remote_peer_connections.is_none() {
1210 self.remote_peer_connections = Some(defaults::REMOTE_PEER_CONNECTIONS);
1211 }
1212 if self.read_repairs_enabled.is_none() {
1213 self.read_repairs_enabled = Some(false);
1214 }
1215 if self.enable_gossip.is_none() {
1216 self.enable_gossip = Some(false);
1217 }
1218 self.apply_hinted_handoff_defaults();
1219 }
1220
1221 fn apply_hinted_handoff_defaults(&mut self) {
1227 if self.enable_hinted_handoff.is_none() {
1228 self.enable_hinted_handoff = Some(defaults::ENABLE_HINTED_HANDOFF);
1229 }
1230 if self.hint_ttl_seconds.is_none() {
1231 self.hint_ttl_seconds = Some(defaults::HINT_TTL_SECONDS);
1232 }
1233 if self.hint_store_max_bytes.is_none() {
1234 self.hint_store_max_bytes = Some(defaults::HINT_STORE_MAX_BYTES);
1235 }
1236 if self.hint_drain_interval_ms.is_none() {
1237 self.hint_drain_interval_ms = Some(defaults::HINT_DRAIN_INTERVAL_MS);
1238 }
1239 if self.transport.is_none() {
1240 self.transport = Some(Transport::default());
1241 }
1242 }
1243
1244 pub fn validate(&self, pool_name: &str) -> Result<(), ConfError> {
1261 if pool_name.is_empty() {
1262 return Err(ConfError::EmptyPoolName);
1263 }
1264
1265 if self.listen.is_none() {
1266 return Err(ConfError::MissingRequired("listen"));
1267 }
1268
1269 self.validate_numeric_ranges()?;
1270 self.validate_mbuf_size()?;
1271 self.validate_max_msgs()?;
1272
1273 if let Some(n) = self.data_store {
1274 let ds = DataStore::from_int(n)?;
1275 if ds == DataStore::Dyniak {
1276 self.validate_dyniak()?;
1277 }
1278 }
1279 if let Some(tag) = &self.hash_tag {
1280 if tag.chars().count() != 2 {
1281 return Err(ConfError::BadHashTag(tag.clone()));
1282 }
1283 }
1284
1285 let secure = if let Some(s) = &self.secure_server_option {
1286 SecureServerOption::parse(s)?
1287 } else {
1288 SecureServerOption::None
1289 };
1290 if let Some(s) = &self.read_consistency {
1291 ConsistencyLevel::parse("read_consistency", s)?;
1292 }
1293 if let Some(s) = &self.write_consistency {
1294 ConsistencyLevel::parse("write_consistency", s)?;
1295 }
1296 if secure != SecureServerOption::None {
1297 match &self.pem_key_file {
1298 Some(s) if !s.is_empty() => {}
1299 _ => return Err(ConfError::MissingRequired("pem_key_file")),
1300 }
1301 }
1302
1303 if let Some(s) = &self.log_format {
1304 crate::core::log::LogFormat::parse(s).map_err(|e| ConfError::BadServer {
1305 field: "log_format",
1306 value: s.clone(),
1307 reason: e.to_string(),
1308 })?;
1309 }
1310
1311 self.validate_bucket_types()?;
1312 self.validate_hinted_handoff()?;
1313 self.validate_peer_tls()?;
1314 self.validate_transport()?;
1315 if let Some(r) = &self.riak {
1316 r.validate()?;
1317 }
1318
1319 match &self.servers {
1320 None => return Err(ConfError::MissingRequired("servers")),
1321 Some(s) if s.is_empty() => return Err(ConfError::MissingRequired("servers")),
1322 Some(s) if s.len() > 1 => {
1323 return Err(ConfError::BadServer {
1324 field: "servers",
1325 value: s.len().to_string(),
1326 reason: "expected exactly one datastore entry".to_string(),
1327 });
1328 }
1329 Some(_) => {}
1330 }
1331
1332 Ok(())
1333 }
1334
1335 fn validate_numeric_ranges(&self) -> Result<(), ConfError> {
1336 check_positive("timeout", self.timeout)?;
1337 check_positive("backlog", self.backlog)?;
1338 check_non_negative("client_connections", self.client_connections)?;
1339 check_positive("server_retry_timeout", self.server_retry_timeout)?;
1340 check_positive("server_failure_limit", self.server_failure_limit)?;
1341 check_positive("dyn_read_timeout", self.dyn_read_timeout)?;
1342 check_positive("dyn_write_timeout", self.dyn_write_timeout)?;
1343 check_positive("gos_interval", self.gos_interval)?;
1344 check_positive("stats_interval", self.stats_interval)?;
1345
1346 if let Some(n) = self.dyn_connections {
1347 if n <= 0 {
1348 return Err(ConfError::OutOfRange {
1349 field: "dyn_connections",
1350 value: n,
1351 reason: "must be a positive non-zero number",
1352 });
1353 }
1354 }
1355 Ok(())
1356 }
1357
1358 fn validate_mbuf_size(&self) -> Result<(), ConfError> {
1359 let Some(n) = self.mbuf_size else {
1360 return Ok(());
1361 };
1362 if n <= 0 {
1363 return Err(ConfError::OutOfRange {
1364 field: "mbuf_size",
1365 value: n,
1366 reason: "must be a positive number",
1367 });
1368 }
1369 if !(defaults::MBUF_MIN_SIZE..=defaults::MBUF_MAX_SIZE).contains(&n) {
1370 return Err(ConfError::OutOfRange {
1371 field: "mbuf_size",
1372 value: n,
1373 reason: "must be between 512 and 512000 bytes",
1374 });
1375 }
1376 if n % 16 != 0 {
1377 return Err(ConfError::OutOfRange {
1378 field: "mbuf_size",
1379 value: n,
1380 reason: "must be a multiple of 16",
1381 });
1382 }
1383 Ok(())
1384 }
1385
1386 fn validate_max_msgs(&self) -> Result<(), ConfError> {
1387 let Some(n) = self.max_msgs else {
1388 return Ok(());
1389 };
1390 if n <= 0 {
1391 return Err(ConfError::OutOfRange {
1392 field: "max_msgs",
1393 value: n,
1394 reason: "requires a non-zero number",
1395 });
1396 }
1397 if !(defaults::ALLOC_MSGS_MIN..=defaults::ALLOC_MSGS_MAX).contains(&n) {
1398 return Err(ConfError::OutOfRange {
1399 field: "max_msgs",
1400 value: n,
1401 reason: "must be between 100000 and 1000000 messages",
1402 });
1403 }
1404 Ok(())
1405 }
1406
1407 fn validate_bucket_types(&self) -> Result<(), ConfError> {
1408 use std::collections::BTreeSet;
1409 let mut seen: BTreeSet<&str> = BTreeSet::new();
1410 for bt in &self.bucket_types {
1411 if bt.name.is_empty() {
1412 return Err(ConfError::BadServer {
1413 field: "bucket_types",
1414 value: String::new(),
1415 reason: "bucket-type name must not be empty".to_string(),
1416 });
1417 }
1418 if !seen.insert(bt.name.as_str()) {
1419 return Err(ConfError::BadServer {
1420 field: "bucket_types",
1421 value: bt.name.clone(),
1422 reason: "duplicate bucket-type name".to_string(),
1423 });
1424 }
1425 ConsistencyLevel::parse("read_consistency", &bt.read_consistency)?;
1426 ConsistencyLevel::parse("write_consistency", &bt.write_consistency)?;
1427 }
1428 if let Some(name) = &self.default_bucket_type {
1429 if !self.bucket_types.iter().any(|bt| &bt.name == name) {
1430 return Err(ConfError::BadServer {
1431 field: "default_bucket_type",
1432 value: name.clone(),
1433 reason: "references an undefined bucket-type name".to_string(),
1434 });
1435 }
1436 }
1437 Ok(())
1438 }
1439
1440 fn validate_dyniak(&self) -> Result<(), ConfError> {
1457 if !crate::conf::is_dyniak_supported() {
1458 return Err(ConfError::BadDyniakConfig(
1459 "dyniak data_store requires dynomited built with --features riak",
1460 ));
1461 }
1462 match self.noxu_path.as_deref() {
1463 Some(p) if !p.as_os_str().is_empty() => Ok(()),
1464 _ => Err(ConfError::BadDyniakConfig(
1465 "data_store: dyniak requires a non-empty 'noxu_path:' directive",
1466 )),
1467 }
1468 }
1469
1470 fn validate_hinted_handoff(&self) -> Result<(), ConfError> {
1471 if self.enable_hinted_handoff != Some(true) {
1472 return Ok(());
1473 }
1474 if let Some(ttl) = self.hint_ttl_seconds {
1475 if ttl == 0 {
1476 return Err(ConfError::BadServer {
1477 field: "hint_ttl_seconds",
1478 value: ttl.to_string(),
1479 reason: "must be a positive number when enable_hinted_handoff is true"
1480 .to_string(),
1481 });
1482 }
1483 }
1484 if let Some(cap) = self.hint_store_max_bytes {
1485 if cap == 0 {
1486 return Err(ConfError::BadServer {
1487 field: "hint_store_max_bytes",
1488 value: cap.to_string(),
1489 reason: "must be a positive number when enable_hinted_handoff is true"
1490 .to_string(),
1491 });
1492 }
1493 }
1494 if let Some(period) = self.hint_drain_interval_ms {
1495 if period == 0 {
1496 return Err(ConfError::BadServer {
1497 field: "hint_drain_interval_ms",
1498 value: period.to_string(),
1499 reason: "must be a positive number when enable_hinted_handoff is true"
1500 .to_string(),
1501 });
1502 }
1503 }
1504 Ok(())
1505 }
1506
1507 fn validate_peer_tls(&self) -> Result<(), ConfError> {
1517 validate_tls_pair(
1518 "peer_tls_cert",
1519 "peer_tls_key",
1520 self.peer_tls_cert.as_deref(),
1521 self.peer_tls_key.as_deref(),
1522 )?;
1523 if self.peer_tls_ca.is_some() && self.peer_tls_cert.is_none() {
1524 return Err(ConfError::BadServer {
1525 field: "peer_tls_ca",
1526 value: self
1527 .peer_tls_ca
1528 .as_ref()
1529 .map_or_else(String::new, |p| p.display().to_string()),
1530 reason: "requires peer_tls_cert and peer_tls_key to also be set".into(),
1531 });
1532 }
1533 for (dc, profile) in &self.peer_tls_profiles {
1534 if dc.is_empty() {
1535 return Err(ConfError::BadServer {
1536 field: "peer_tls_profiles",
1537 value: String::new(),
1538 reason: "per-DC TLS profile name must not be empty".into(),
1539 });
1540 }
1541 profile.validate(dc)?;
1542 }
1543 Ok(())
1544 }
1545
1546 fn validate_transport(&self) -> Result<(), ConfError> {
1557 let resolved = self.transport.unwrap_or_default();
1558 if resolved != Transport::Quic {
1559 return Ok(());
1560 }
1561 match (
1562 self.quic_cert_file.as_deref(),
1563 self.quic_key_file.as_deref(),
1564 ) {
1565 (Some(_), Some(_)) => Ok(()),
1566 (None, _) => Err(ConfError::BadServer {
1567 field: "quic_cert_file",
1568 value: String::new(),
1569 reason: "transport: quic requires quic_cert_file to be set".into(),
1570 }),
1571 (Some(_), None) => Err(ConfError::BadServer {
1572 field: "quic_key_file",
1573 value: String::new(),
1574 reason: "transport: quic requires quic_key_file to be set".into(),
1575 }),
1576 }
1577 }
1578}
1579
1580impl fmt::Display for ConfPool {
1581 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1582 match serde_yaml::to_string(self) {
1586 Ok(s) => f.write_str(&s),
1587 Err(_) => Err(fmt::Error),
1588 }
1589 }
1590}
1591
1592fn check_positive(field: &'static str, v: Option<i64>) -> Result<(), ConfError> {
1593 if let Some(n) = v {
1594 if n <= 0 {
1595 return Err(ConfError::OutOfRange {
1596 field,
1597 value: n,
1598 reason: "must be a positive number",
1599 });
1600 }
1601 }
1602 Ok(())
1603}
1604
1605fn check_non_negative(field: &'static str, v: Option<i64>) -> Result<(), ConfError> {
1606 if let Some(n) = v {
1607 if n < 0 {
1608 return Err(ConfError::OutOfRange {
1609 field,
1610 value: n,
1611 reason: "must be a non-negative number",
1612 });
1613 }
1614 }
1615 Ok(())
1616}
1617
1618fn deserialize_data_store<'de, D>(de: D) -> Result<Option<i64>, D::Error>
1624where
1625 D: serde::Deserializer<'de>,
1626{
1627 use serde::de::{self, Visitor};
1628 use std::fmt;
1629
1630 struct V;
1631 impl<'de> Visitor<'de> for V {
1632 type Value = Option<i64>;
1633
1634 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1635 f.write_str(
1636 "a data_store value: integer (0, 1, 2) or string (valkey, memcache, dyniak)",
1637 )
1638 }
1639
1640 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1641 Ok(None)
1642 }
1643
1644 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1645 Ok(None)
1646 }
1647
1648 fn visit_some<D2: serde::Deserializer<'de>>(
1649 self,
1650 de: D2,
1651 ) -> Result<Self::Value, D2::Error> {
1652 de.deserialize_any(V)
1653 }
1654
1655 fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
1656 DataStore::from_int(v)
1657 .map(|d| Some(d.as_int()))
1658 .map_err(|e| E::custom(e.to_string()))
1659 }
1660
1661 fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
1662 let n = i64::try_from(v).map_err(|_| E::custom("data_store integer overflow"))?;
1663 self.visit_i64(n)
1664 }
1665
1666 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1667 DataStore::from_name(v)
1668 .map(|d| Some(d.as_int()))
1669 .map_err(|_| {
1670 E::custom(format!(
1671 "data_store: unknown name '{v}'; expected one of: valkey, memcache, dyniak"
1672 ))
1673 })
1674 }
1675
1676 fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
1677 self.visit_str(&v)
1678 }
1679 }
1680
1681 de.deserialize_any(V)
1682}
1683
1684#[cfg(test)]
1685mod tests {
1686 use super::*;
1687
1688 fn pool() -> ConfPool {
1689 ConfPool {
1690 listen: Some(ConfListen::parse("listen", "127.0.0.1:8102").unwrap()),
1691 servers: Some(Servers::from_vec(vec![ConfServer::parse(
1692 "127.0.0.1:6379:1",
1693 )
1694 .unwrap()])),
1695 tokens: Some(TokenList::parse("0").unwrap()),
1696 ..ConfPool::default()
1697 }
1698 }
1699
1700 #[test]
1701 fn validate_minimal_post_finalize() {
1702 let mut p = pool();
1703 p.apply_defaults();
1704 p.validate("dyn_o_mite").unwrap();
1705 }
1706
1707 #[test]
1708 fn missing_listen_rejected() {
1709 let mut p = pool();
1710 p.listen = None;
1711 p.apply_defaults();
1712 assert!(matches!(
1713 p.validate("p"),
1714 Err(ConfError::MissingRequired("listen"))
1715 ));
1716 }
1717
1718 #[test]
1719 fn out_of_range_mbuf_rejected() {
1720 let mut p = pool();
1721 p.mbuf_size = Some(127);
1722 p.apply_defaults();
1723 assert!(matches!(p.validate("p"), Err(ConfError::OutOfRange { .. })));
1724 }
1725
1726 #[test]
1727 fn distribution_field_round_trips_through_yaml() {
1728 let yaml = r"
1729p:
1730 listen: 127.0.0.1:8102
1731 dyn_listen: 127.0.0.1:8101
1732 tokens: '0'
1733 servers:
1734 - 127.0.0.1:6379:1
1735 data_store: 0
1736 distribution: random_slicing
1737 distribution_shadow: vnode
1738 hash: murmur3_x64_64
1739";
1740 let parsed: std::collections::BTreeMap<String, ConfPool> =
1741 serde_yaml::from_str(yaml).unwrap();
1742 let pool = parsed.get("p").unwrap();
1743 assert_eq!(pool.distribution, Some(Distribution::RandomSlicing));
1744 assert_eq!(pool.distribution_shadow, Some(Distribution::Vnode));
1745 assert_eq!(pool.hash, Some(HashType::Murmur3X64_64));
1746 assert_eq!(pool.resolved_distribution(), Distribution::RandomSlicing);
1747 }
1748
1749 #[test]
1750 fn distribution_legacy_alias_resolves_to_vnode() {
1751 let mut p = pool();
1752 p.distribution = Some(Distribution::Ketama);
1753 assert_eq!(p.resolved_distribution(), Distribution::Vnode);
1754 p.distribution = Some(Distribution::Modula);
1755 assert_eq!(p.resolved_distribution(), Distribution::Vnode);
1756 p.distribution = Some(Distribution::Random);
1757 assert_eq!(p.resolved_distribution(), Distribution::Vnode);
1758 }
1759
1760 #[test]
1761 fn distribution_default_unset_is_vnode() {
1762 let p = pool();
1763 assert!(p.distribution.is_none());
1764 assert_eq!(p.resolved_distribution(), Distribution::Vnode);
1765 }
1766
1767 #[test]
1768 fn mbuf_size_not_multiple_of_16_rejected() {
1769 let mut p = pool();
1770 p.mbuf_size = Some(513);
1771 p.apply_defaults();
1772 assert!(matches!(p.validate("p"), Err(ConfError::OutOfRange { .. })));
1773 }
1774
1775 #[test]
1776 fn pem_required_when_secure() {
1777 let mut p = pool();
1778 p.secure_server_option = Some("datacenter".to_string());
1779 p.pem_key_file = Some(String::new());
1780 p.apply_defaults();
1781 assert!(matches!(
1784 p.validate("p"),
1785 Err(ConfError::MissingRequired("pem_key_file"))
1786 ));
1787 }
1788
1789 #[test]
1790 fn data_store_out_of_range_rejected() {
1791 let mut p = pool();
1792 p.data_store = Some(7);
1793 p.apply_defaults();
1794 assert!(matches!(p.validate("p"), Err(ConfError::BadDataStore(7))));
1795 }
1796
1797 static DYNIAK_FLAG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1802
1803 #[test]
1804 fn data_store_dyniak_requires_riak_feature() {
1805 let _g = DYNIAK_FLAG_LOCK
1806 .lock()
1807 .unwrap_or_else(std::sync::PoisonError::into_inner);
1808 let prev = crate::conf::is_dyniak_supported();
1811 crate::conf::set_dyniak_supported(false);
1812 let mut p = pool();
1813 p.data_store = Some(2);
1814 p.noxu_path = Some("/scratch/test".into());
1815 p.apply_defaults();
1816 let err = p.validate("p");
1817 crate::conf::set_dyniak_supported(prev);
1818 match err {
1819 Err(ConfError::BadDyniakConfig(msg)) => {
1820 assert!(msg.contains("--features riak"), "unexpected message: {msg}");
1821 }
1822 other => panic!("expected BadDyniakConfig, got {other:?}"),
1823 }
1824 }
1825
1826 #[test]
1827 fn data_store_dyniak_requires_path() {
1828 let _g = DYNIAK_FLAG_LOCK
1829 .lock()
1830 .unwrap_or_else(std::sync::PoisonError::into_inner);
1831 let prev = crate::conf::is_dyniak_supported();
1832 crate::conf::set_dyniak_supported(true);
1833 let mut p = pool();
1834 p.data_store = Some(2);
1835 p.noxu_path = None;
1836 p.apply_defaults();
1837 let err = p.validate("p");
1838 crate::conf::set_dyniak_supported(prev);
1839 match err {
1840 Err(ConfError::BadDyniakConfig(msg)) => {
1841 assert!(msg.contains("noxu_path"), "unexpected message: {msg}");
1842 }
1843 other => panic!("expected BadDyniakConfig, got {other:?}"),
1844 }
1845 }
1846
1847 #[test]
1848 fn data_store_dyniak_yaml_round_trip_string_form() {
1849 let yaml = r"
1852listen: 127.0.0.1:8102
1853servers:
1854- 127.0.0.1:6379:1
1855tokens: '0'
1856data_store: dyniak
1857noxu_path: /scratch/test
1858";
1859 let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
1860 assert_eq!(p.data_store, Some(2));
1861 assert_eq!(
1862 p.noxu_path.as_deref(),
1863 Some(std::path::Path::new("/scratch/test"))
1864 );
1865 let dumped = serde_yaml::to_string(&p).unwrap();
1867 let p2: ConfPool = serde_yaml::from_str(&dumped).unwrap();
1868 assert_eq!(p2.data_store, p.data_store);
1869 assert_eq!(p2.noxu_path, p.noxu_path);
1870 }
1871
1872 #[test]
1873 fn data_store_redis_alias_maps_to_valkey() {
1874 let yaml = r"
1877listen: 127.0.0.1:8102
1878servers:
1879- 127.0.0.1:6379:1
1880tokens: '0'
1881data_store: redis
1882";
1883 let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
1884 assert_eq!(p.data_store, Some(0));
1885 }
1886
1887 #[test]
1888 fn data_store_yaml_int_form_still_works() {
1889 let yaml = r"
1890listen: 127.0.0.1:8102
1891servers:
1892- 127.0.0.1:6379:1
1893tokens: '0'
1894data_store: 2
1895noxu_path: /scratch/test
1896";
1897 let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
1898 assert_eq!(p.data_store, Some(2));
1899 }
1900
1901 #[test]
1902 fn data_store_string_form_unknown_rejected() {
1903 let yaml = r"
1904listen: 127.0.0.1:8102
1905servers:
1906- 127.0.0.1:6379:1
1907tokens: '0'
1908data_store: postgres
1909";
1910 let err = serde_yaml::from_str::<ConfPool>(yaml).unwrap_err();
1911 let msg = err.to_string();
1912 assert!(
1913 msg.contains("unknown name") || msg.contains("data_store"),
1914 "unexpected message: {msg}"
1915 );
1916 }
1917
1918 #[test]
1919 fn hash_tag_must_be_two_chars() {
1920 let mut p = pool();
1921 p.hash_tag = Some("abc".to_string());
1922 p.apply_defaults();
1923 assert!(matches!(p.validate("p"), Err(ConfError::BadHashTag(_))));
1924 }
1925
1926 #[test]
1927 fn empty_servers_rejected() {
1928 let mut p = pool();
1929 p.servers = Some(Servers::from_vec(vec![]));
1930 p.apply_defaults();
1931 assert!(matches!(
1932 p.validate("p"),
1933 Err(ConfError::MissingRequired("servers"))
1934 ));
1935 }
1936
1937 #[test]
1938 fn log_format_known_values_accepted() {
1939 for value in ["default", "rfc5424", "rfc3164", "json", "ndjson", "DEFAULT"] {
1940 let mut p = pool();
1941 p.log_format = Some(value.to_string());
1942 p.apply_defaults();
1943 assert!(p.validate("p").is_ok(), "value {value:?} should validate");
1944 }
1945 }
1946
1947 #[test]
1948 fn log_format_unknown_rejected() {
1949 let mut p = pool();
1950 p.log_format = Some("yaml".to_string());
1951 p.apply_defaults();
1952 let err = p.validate("p").unwrap_err();
1953 assert!(
1954 matches!(
1955 err,
1956 ConfError::BadServer {
1957 field: "log_format",
1958 ..
1959 }
1960 ),
1961 "unexpected error: {err:?}"
1962 );
1963 }
1964
1965 #[test]
1966 fn observability_block_round_trips() {
1967 let yaml = r"
1968observability:
1969 otlp_logs_endpoint: http://collector:4317
1970 service_name: dynomited
1971listen: 127.0.0.1:8102
1972servers:
1973- 127.0.0.1:6379:1
1974tokens: '0'
1975";
1976 let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
1977 let obs = p.observability.as_ref().expect("observability set");
1978 assert_eq!(
1979 obs.otlp_logs_endpoint.as_deref(),
1980 Some("http://collector:4317")
1981 );
1982 assert_eq!(obs.service_name.as_deref(), Some("dynomited"));
1983 }
1984
1985 #[test]
1986 fn bucket_types_round_trip() {
1987 let yaml = r"
1988listen: 127.0.0.1:8102
1989servers:
1990- 127.0.0.1:6379:1
1991tokens: '0'
1992bucket_types:
1993- name: hot
1994 read_consistency: DC_QUORUM
1995 write_consistency: DC_EACH_SAFE_QUORUM
1996 n_val: 3
1997- name: cold
1998 read_consistency: DC_ONE
1999 write_consistency: DC_ONE
2000 n_val: 1
2001default_bucket_type: cold
2002";
2003 let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
2004 assert_eq!(p.bucket_types.len(), 2);
2005 assert_eq!(p.bucket_types[0].name, "hot");
2006 assert_eq!(p.bucket_types[0].n_val, 3);
2007 assert_eq!(
2008 p.bucket_types[0].read_level().unwrap(),
2009 crate::conf::ConsistencyLevel::DcQuorum,
2010 );
2011 assert_eq!(p.default_bucket_type.as_deref(), Some("cold"));
2012 let dumped = serde_yaml::to_string(&p).unwrap();
2014 let p2: ConfPool = serde_yaml::from_str(&dumped).unwrap();
2015 assert_eq!(p2.bucket_types, p.bucket_types);
2016 assert_eq!(p2.default_bucket_type, p.default_bucket_type);
2017 }
2018
2019 #[test]
2020 fn bucket_types_default_is_empty() {
2021 let mut p = pool();
2022 p.apply_defaults();
2023 assert!(p.bucket_types.is_empty());
2024 assert!(p.default_bucket_type.is_none());
2025 assert!(p.validate("p").is_ok());
2026 }
2027
2028 #[test]
2029 fn duplicate_bucket_type_name_rejected() {
2030 let mut p = pool();
2031 p.bucket_types = vec![
2032 ConfBucketType {
2033 name: "a".into(),
2034 read_consistency: "DC_ONE".into(),
2035 write_consistency: "DC_ONE".into(),
2036 n_val: 0,
2037 },
2038 ConfBucketType {
2039 name: "a".into(),
2040 read_consistency: "DC_ONE".into(),
2041 write_consistency: "DC_ONE".into(),
2042 n_val: 0,
2043 },
2044 ];
2045 p.apply_defaults();
2046 let err = p.validate("p").unwrap_err();
2047 assert!(
2048 matches!(
2049 err,
2050 ConfError::BadServer {
2051 field: "bucket_types",
2052 ..
2053 }
2054 ),
2055 "unexpected error: {err:?}",
2056 );
2057 }
2058
2059 #[test]
2060 fn bucket_type_unknown_consistency_rejected() {
2061 let mut p = pool();
2062 p.bucket_types = vec![ConfBucketType {
2063 name: "a".into(),
2064 read_consistency: "DC_PURPLE".into(),
2065 write_consistency: "DC_ONE".into(),
2066 n_val: 0,
2067 }];
2068 p.apply_defaults();
2069 let err = p.validate("p").unwrap_err();
2070 assert!(matches!(err, ConfError::BadConsistency { .. }));
2071 }
2072
2073 #[test]
2074 fn unknown_default_bucket_type_rejected() {
2075 let mut p = pool();
2076 p.default_bucket_type = Some("missing".into());
2077 p.apply_defaults();
2078 let err = p.validate("p").unwrap_err();
2079 assert!(matches!(
2080 err,
2081 ConfError::BadServer {
2082 field: "default_bucket_type",
2083 ..
2084 }
2085 ));
2086 }
2087
2088 #[test]
2089 fn hinted_handoff_default_off_with_canonical_constants() {
2090 let mut p = pool();
2091 p.apply_defaults();
2092 assert_eq!(p.enable_hinted_handoff, Some(false));
2093 assert_eq!(p.hint_ttl_seconds, Some(defaults::HINT_TTL_SECONDS));
2094 assert_eq!(p.hint_store_max_bytes, Some(defaults::HINT_STORE_MAX_BYTES));
2095 assert_eq!(
2096 p.hint_drain_interval_ms,
2097 Some(defaults::HINT_DRAIN_INTERVAL_MS)
2098 );
2099 assert!(p.validate("p").is_ok());
2100 }
2101
2102 #[test]
2103 fn hinted_handoff_yaml_round_trip() {
2104 let yaml = r"
2105listen: 127.0.0.1:8102
2106servers:
2107- 127.0.0.1:6379:1
2108tokens: '0'
2109enable_hinted_handoff: true
2110hint_ttl_seconds: 7200
2111hint_store_max_bytes: 8388608
2112hint_drain_interval_ms: 5000
2113hint_dir: /scratch/dynomite-hints
2114";
2115 let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
2116 assert_eq!(p.enable_hinted_handoff, Some(true));
2117 assert_eq!(p.hint_ttl_seconds, Some(7200));
2118 assert_eq!(p.hint_store_max_bytes, Some(8_388_608));
2119 assert_eq!(p.hint_drain_interval_ms, Some(5_000));
2120 assert_eq!(
2121 p.hint_dir.as_deref(),
2122 Some(std::path::Path::new("/scratch/dynomite-hints"))
2123 );
2124 let dumped = serde_yaml::to_string(&p).unwrap();
2125 let p2: ConfPool = serde_yaml::from_str(&dumped).unwrap();
2126 assert_eq!(p2.enable_hinted_handoff, p.enable_hinted_handoff);
2127 assert_eq!(p2.hint_ttl_seconds, p.hint_ttl_seconds);
2128 assert_eq!(p2.hint_store_max_bytes, p.hint_store_max_bytes);
2129 assert_eq!(p2.hint_drain_interval_ms, p.hint_drain_interval_ms);
2130 assert_eq!(p2.hint_dir, p.hint_dir);
2131 }
2132
2133 #[test]
2134 fn hinted_handoff_zero_ttl_rejected_when_enabled() {
2135 let mut p = pool();
2136 p.enable_hinted_handoff = Some(true);
2137 p.hint_ttl_seconds = Some(0);
2138 p.apply_defaults();
2139 let err = p.validate("p").unwrap_err();
2142 assert!(matches!(
2143 err,
2144 ConfError::BadServer {
2145 field: "hint_ttl_seconds",
2146 ..
2147 }
2148 ));
2149 }
2150
2151 #[test]
2152 fn hinted_handoff_zero_max_bytes_rejected_when_enabled() {
2153 let mut p = pool();
2154 p.enable_hinted_handoff = Some(true);
2155 p.hint_store_max_bytes = Some(0);
2156 p.apply_defaults();
2157 let err = p.validate("p").unwrap_err();
2158 assert!(matches!(
2159 err,
2160 ConfError::BadServer {
2161 field: "hint_store_max_bytes",
2162 ..
2163 }
2164 ));
2165 }
2166
2167 #[test]
2168 fn hinted_handoff_zero_values_ignored_when_disabled() {
2169 let mut p = pool();
2173 p.enable_hinted_handoff = Some(false);
2174 p.hint_ttl_seconds = Some(0);
2175 p.hint_store_max_bytes = Some(0);
2176 p.hint_drain_interval_ms = Some(0);
2177 p.apply_defaults();
2178 assert!(p.validate("p").is_ok());
2179 }
2180
2181 #[test]
2182 fn riak_block_validates_when_unset() {
2183 let mut p = pool();
2184 p.riak = Some(ConfRiak::default());
2185 p.apply_defaults();
2186 assert!(p.validate("p").is_ok());
2187 }
2188
2189 #[test]
2190 fn riak_block_validates_with_addresses() {
2191 let mut p = pool();
2192 p.riak = Some(ConfRiak {
2193 pbc_listen: Some("127.0.0.1:8087".into()),
2194 http_listen: Some("127.0.0.1:8098".into()),
2195 ..ConfRiak::default()
2196 });
2197 p.apply_defaults();
2198 assert!(p.validate("p").is_ok());
2199 }
2200
2201 #[test]
2202 fn riak_block_rejects_bad_pbc_addr() {
2203 let mut p = pool();
2204 p.riak = Some(ConfRiak {
2205 pbc_listen: Some(String::new()),
2206 ..ConfRiak::default()
2207 });
2208 p.apply_defaults();
2209 assert!(matches!(p.validate("p"), Err(ConfError::BadServer { .. })));
2210 }
2211
2212 #[test]
2213 fn riak_block_rejects_segment_above_full_sweep() {
2214 let mut p = pool();
2215 p.riak = Some(ConfRiak {
2216 aae_segment_interval_seconds: Some(120),
2217 aae_full_sweep_interval_seconds: Some(60),
2218 ..ConfRiak::default()
2219 });
2220 p.apply_defaults();
2221 assert!(matches!(p.validate("p"), Err(ConfError::BadServer { .. })));
2222 }
2223
2224 #[test]
2225 fn riak_block_round_trips_through_yaml() {
2226 let yaml = r"
2227p:
2228 listen: 127.0.0.1:1
2229 dyn_listen: 127.0.0.1:2
2230 tokens: '0'
2231 servers:
2232 - 127.0.0.1:3:1
2233 data_store: 0
2234 riak:
2235 pbc_listen: 127.0.0.1:8087
2236 http_listen: 127.0.0.1:8098
2237 aae_enabled: true
2238 aae_full_sweep_interval_seconds: 3600
2239 aae_segment_interval_seconds: 30
2240";
2241 let cfg: std::collections::BTreeMap<String, ConfPool> = serde_yaml::from_str(yaml).unwrap();
2242 let p = cfg.get("p").unwrap();
2243 let r = p.riak.as_ref().unwrap();
2244 assert_eq!(r.pbc_listen.as_deref(), Some("127.0.0.1:8087"));
2245 assert_eq!(r.http_listen.as_deref(), Some("127.0.0.1:8098"));
2246 assert_eq!(r.aae_enabled, Some(true));
2247 assert_eq!(r.aae_full_sweep_interval_seconds, Some(3600));
2248 assert_eq!(r.aae_segment_interval_seconds, Some(30));
2249 }
2250
2251 #[test]
2252 fn peer_tls_pair_unset_is_ok() {
2253 let mut p = pool();
2254 p.apply_defaults();
2255 assert!(p.validate("p").is_ok(), "plaintext default must validate");
2256 }
2257
2258 #[test]
2259 fn peer_tls_pair_both_set_is_ok() {
2260 let mut p = pool();
2261 p.peer_tls_cert = Some(std::path::PathBuf::from("/etc/dynomite/peer.crt"));
2262 p.peer_tls_key = Some(std::path::PathBuf::from("/etc/dynomite/peer.key"));
2263 p.apply_defaults();
2264 assert!(p.validate("p").is_ok());
2265 }
2266
2267 #[test]
2268 fn peer_tls_cert_without_key_rejected() {
2269 let mut p = pool();
2270 p.peer_tls_cert = Some(std::path::PathBuf::from("/x.crt"));
2271 p.apply_defaults();
2272 let err = p.validate("p").unwrap_err();
2273 assert!(
2274 matches!(
2275 err,
2276 ConfError::BadServer {
2277 field: "peer_tls_cert",
2278 ..
2279 }
2280 ),
2281 "got {err:?}"
2282 );
2283 }
2284
2285 #[test]
2286 fn peer_tls_key_without_cert_rejected() {
2287 let mut p = pool();
2288 p.peer_tls_key = Some(std::path::PathBuf::from("/x.key"));
2289 p.apply_defaults();
2290 let err = p.validate("p").unwrap_err();
2291 assert!(
2292 matches!(
2293 err,
2294 ConfError::BadServer {
2295 field: "peer_tls_key",
2296 ..
2297 }
2298 ),
2299 "got {err:?}"
2300 );
2301 }
2302
2303 #[test]
2304 fn peer_tls_ca_without_cert_rejected() {
2305 let mut p = pool();
2306 p.peer_tls_ca = Some(std::path::PathBuf::from("/x.ca"));
2307 p.apply_defaults();
2308 let err = p.validate("p").unwrap_err();
2309 assert!(
2310 matches!(
2311 err,
2312 ConfError::BadServer {
2313 field: "peer_tls_ca",
2314 ..
2315 }
2316 ),
2317 "got {err:?}"
2318 );
2319 }
2320
2321 #[test]
2322 fn riak_tls_cert_without_key_rejected() {
2323 let mut p = pool();
2324 p.riak = Some(ConfRiak {
2325 pbc_listen: Some("127.0.0.1:8087".into()),
2326 tls_cert: Some(std::path::PathBuf::from("/x.crt")),
2327 ..ConfRiak::default()
2328 });
2329 p.apply_defaults();
2330 let err = p.validate("p").unwrap_err();
2331 assert!(
2332 matches!(
2333 err,
2334 ConfError::BadServer {
2335 field: "tls_cert",
2336 ..
2337 }
2338 ),
2339 "got {err:?}"
2340 );
2341 }
2342
2343 #[test]
2344 fn riak_tls_pair_both_set_is_ok() {
2345 let mut p = pool();
2346 p.riak = Some(ConfRiak {
2347 pbc_listen: Some("127.0.0.1:8087".into()),
2348 tls_cert: Some(std::path::PathBuf::from("/x.crt")),
2349 tls_key: Some(std::path::PathBuf::from("/x.key")),
2350 ..ConfRiak::default()
2351 });
2352 p.apply_defaults();
2353 assert!(p.validate("p").is_ok());
2354 }
2355
2356 #[test]
2357 fn riak_quic_listen_requires_tls_pair() {
2358 let mut p = pool();
2359 p.riak = Some(ConfRiak {
2360 quic_listen: Some("127.0.0.1:8089".into()),
2361 ..ConfRiak::default()
2362 });
2363 p.apply_defaults();
2364 let Err(ConfError::BadServer { field, .. }) = p.validate("p") else {
2365 panic!("quic_listen without tls_cert/tls_key must be rejected");
2366 };
2367 assert_eq!(field, "quic_listen");
2368 }
2369
2370 #[test]
2371 fn riak_quic_listen_with_tls_pair_is_ok() {
2372 let mut p = pool();
2373 p.riak = Some(ConfRiak {
2374 quic_listen: Some("127.0.0.1:8089".into()),
2375 tls_cert: Some(std::path::PathBuf::from("/x.crt")),
2376 tls_key: Some(std::path::PathBuf::from("/x.key")),
2377 ..ConfRiak::default()
2378 });
2379 p.apply_defaults();
2380 assert!(p.validate("p").is_ok());
2381 }
2382
2383 #[test]
2384 fn riak_quic_listen_rejects_bad_addr() {
2385 let mut p = pool();
2386 p.riak = Some(ConfRiak {
2387 quic_listen: Some("not-an-addr".into()),
2388 tls_cert: Some(std::path::PathBuf::from("/x.crt")),
2389 tls_key: Some(std::path::PathBuf::from("/x.key")),
2390 ..ConfRiak::default()
2391 });
2392 p.apply_defaults();
2393 assert!(p.validate("p").is_err());
2394 }
2395
2396 #[test]
2397 fn riak_wasm_modules_yaml_round_trip() {
2398 let dir = tempfile::tempdir().unwrap();
2399 let m1 = dir.path().join("identity.wasm");
2400 let m2 = dir.path().join("sum.wasm");
2401 std::fs::write(&m1, b"\0asm\x01\0\0\0").unwrap();
2402 std::fs::write(&m2, b"\0asm\x01\0\0\0").unwrap();
2403 let yaml = format!(
2404 r"
2405listen: 127.0.0.1:8102
2406servers:
2407- 127.0.0.1:6379:1
2408tokens: '0'
2409riak:
2410 pbc_listen: 127.0.0.1:8087
2411 wasm_modules:
2412 - id: identity
2413 path: {m1}
2414 - id: sum
2415 path: {m2}
2416",
2417 m1 = m1.display(),
2418 m2 = m2.display(),
2419 );
2420 let p: ConfPool = serde_yaml::from_str(&yaml).unwrap();
2421 let r = p.riak.as_ref().unwrap();
2422 let mods = r.wasm_modules.as_ref().unwrap();
2423 assert_eq!(mods.len(), 2);
2424 assert_eq!(mods[0].id, "identity");
2425 assert_eq!(mods[0].path, m1);
2426 assert_eq!(mods[1].id, "sum");
2427 assert_eq!(mods[1].path, m2);
2428 let dumped = serde_yaml::to_string(&p).unwrap();
2430 let p2: ConfPool = serde_yaml::from_str(&dumped).unwrap();
2431 assert_eq!(p2.riak.unwrap().wasm_modules, r.wasm_modules);
2432 }
2433
2434 #[test]
2435 fn riak_wasm_modules_unique_ids_required() {
2436 let dir = tempfile::tempdir().unwrap();
2437 let path = dir.path().join("m.wasm");
2438 std::fs::write(&path, b"\0").unwrap();
2439 let r = ConfRiak {
2440 wasm_modules: Some(vec![
2441 ConfRiakWasmModule {
2442 id: "m".into(),
2443 path: path.clone(),
2444 },
2445 ConfRiakWasmModule {
2446 id: "m".into(),
2447 path: path.clone(),
2448 },
2449 ]),
2450 ..ConfRiak::default()
2451 };
2452 let err = r.validate().unwrap_err();
2453 assert!(matches!(
2454 err,
2455 ConfError::BadServer {
2456 field: "wasm_modules.id",
2457 ..
2458 }
2459 ));
2460 }
2461
2462 #[test]
2463 fn riak_wasm_modules_path_must_exist() {
2464 let r = ConfRiak {
2465 wasm_modules: Some(vec![ConfRiakWasmModule {
2466 id: "missing".into(),
2467 path: std::path::PathBuf::from("/no/such/path/at/all.wasm"),
2468 }]),
2469 ..ConfRiak::default()
2470 };
2471 let err = r.validate().unwrap_err();
2472 assert!(matches!(
2473 err,
2474 ConfError::BadServer {
2475 field: "wasm_modules.path",
2476 ..
2477 }
2478 ));
2479 }
2480
2481 #[test]
2482 fn riak_wasm_modules_empty_id_rejected() {
2483 let dir = tempfile::tempdir().unwrap();
2484 let path = dir.path().join("m.wasm");
2485 std::fs::write(&path, b"\0").unwrap();
2486 let r = ConfRiak {
2487 wasm_modules: Some(vec![ConfRiakWasmModule {
2488 id: String::new(),
2489 path,
2490 }]),
2491 ..ConfRiak::default()
2492 };
2493 let err = r.validate().unwrap_err();
2494 assert!(matches!(
2495 err,
2496 ConfError::BadServer {
2497 field: "wasm_modules.id",
2498 ..
2499 }
2500 ));
2501 }
2502
2503 #[test]
2504 fn peer_tls_profile_pair_unset_is_ok() {
2505 let p = ConfTlsProfile::default();
2506 assert!(p.validate("dc1").is_ok());
2507 }
2508
2509 #[test]
2510 fn peer_tls_profile_cert_without_key_rejected() {
2511 let p = ConfTlsProfile {
2512 cert: Some(std::path::PathBuf::from("/x.crt")),
2513 ..ConfTlsProfile::default()
2514 };
2515 let err = p.validate("dc1").unwrap_err();
2516 assert!(matches!(
2517 err,
2518 ConfError::BadServer {
2519 field: "peer_tls_profiles.cert",
2520 ..
2521 }
2522 ));
2523 }
2524
2525 #[test]
2526 fn peer_tls_profile_key_without_cert_rejected() {
2527 let p = ConfTlsProfile {
2528 key: Some(std::path::PathBuf::from("/x.key")),
2529 ..ConfTlsProfile::default()
2530 };
2531 let err = p.validate("dc1").unwrap_err();
2532 assert!(matches!(
2533 err,
2534 ConfError::BadServer {
2535 field: "peer_tls_profiles.key",
2536 ..
2537 }
2538 ));
2539 }
2540
2541 #[test]
2542 fn peer_tls_profile_ca_without_cert_rejected() {
2543 let p = ConfTlsProfile {
2544 ca: Some(std::path::PathBuf::from("/x.ca")),
2545 ..ConfTlsProfile::default()
2546 };
2547 let err = p.validate("dc1").unwrap_err();
2548 assert!(matches!(
2549 err,
2550 ConfError::BadServer {
2551 field: "peer_tls_profiles.ca",
2552 ..
2553 }
2554 ));
2555 }
2556
2557 #[test]
2558 fn peer_tls_profiles_empty_dc_name_rejected() {
2559 let mut p = pool();
2560 p.peer_tls_profiles.insert(
2561 String::new(),
2562 ConfTlsProfile {
2563 cert: Some(std::path::PathBuf::from("/x.crt")),
2564 key: Some(std::path::PathBuf::from("/x.key")),
2565 ca: None,
2566 },
2567 );
2568 p.apply_defaults();
2569 let err = p.validate("p").unwrap_err();
2570 assert!(matches!(
2571 err,
2572 ConfError::BadServer {
2573 field: "peer_tls_profiles",
2574 ..
2575 }
2576 ));
2577 }
2578
2579 #[test]
2580 fn peer_tls_profiles_per_dc_pair_validates() {
2581 let mut p = pool();
2582 p.peer_tls_profiles.insert(
2583 "dc1".into(),
2584 ConfTlsProfile {
2585 cert: Some(std::path::PathBuf::from("/dc1.crt")),
2586 key: Some(std::path::PathBuf::from("/dc1.key")),
2587 ca: None,
2588 },
2589 );
2590 p.apply_defaults();
2591 assert!(p.validate("p").is_ok());
2592 }
2593
2594 #[test]
2595 fn peer_tls_profiles_per_dc_cert_without_key_rejected() {
2596 let mut p = pool();
2597 p.peer_tls_profiles.insert(
2598 "dc1".into(),
2599 ConfTlsProfile {
2600 cert: Some(std::path::PathBuf::from("/dc1.crt")),
2601 key: None,
2602 ca: None,
2603 },
2604 );
2605 p.apply_defaults();
2606 let err = p.validate("p").unwrap_err();
2607 assert!(matches!(
2608 err,
2609 ConfError::BadServer {
2610 field: "peer_tls_profiles.cert",
2611 ..
2612 }
2613 ));
2614 }
2615
2616 #[test]
2617 fn peer_tls_profiles_yaml_round_trip() {
2618 let yaml = r"
2619listen: 127.0.0.1:8102
2620servers:
2621- 127.0.0.1:6379:1
2622tokens: '0'
2623peer_tls_profiles:
2624 dc1:
2625 cert: /etc/dynomite/dc1.pem
2626 key: /etc/dynomite/dc1.key
2627 ca: /etc/dynomite/dc1-ca.pem
2628 dc2:
2629 cert: /etc/dynomite/dc2.pem
2630 key: /etc/dynomite/dc2.key
2631";
2632 let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
2633 assert_eq!(p.peer_tls_profiles.len(), 2);
2634 assert_eq!(
2635 p.peer_tls_profiles["dc1"].cert.as_deref(),
2636 Some(std::path::Path::new("/etc/dynomite/dc1.pem"))
2637 );
2638 assert!(p.peer_tls_profiles["dc2"].ca.is_none());
2639 let dumped = serde_yaml::to_string(&p).unwrap();
2640 let p2: ConfPool = serde_yaml::from_str(&dumped).unwrap();
2641 assert_eq!(p2.peer_tls_profiles, p.peer_tls_profiles);
2642 }
2643
2644 #[test]
2645 fn transport_default_is_tcp_after_finalize() {
2646 let mut p = pool();
2647 p.apply_defaults();
2648 assert_eq!(p.transport, Some(Transport::Tcp));
2649 assert!(p.validate("p").is_ok());
2650 }
2651
2652 #[test]
2653 fn transport_quic_yaml_round_trip() {
2654 let yaml = r"
2655listen: 127.0.0.1:8102
2656servers:
2657- 127.0.0.1:6379:1
2658tokens: '0'
2659transport: quic
2660quic_cert_file: /tmp/test.crt
2661quic_key_file: /tmp/test.key
2662";
2663 let p: ConfPool = serde_yaml::from_str(yaml).unwrap();
2664 assert_eq!(p.transport, Some(Transport::Quic));
2665 assert_eq!(
2666 p.quic_cert_file.as_deref(),
2667 Some(std::path::Path::new("/tmp/test.crt"))
2668 );
2669 assert_eq!(
2670 p.quic_key_file.as_deref(),
2671 Some(std::path::Path::new("/tmp/test.key"))
2672 );
2673 let dumped = serde_yaml::to_string(&p).unwrap();
2674 let p2: ConfPool = serde_yaml::from_str(&dumped).unwrap();
2675 assert_eq!(p2.transport, p.transport);
2676 assert_eq!(p2.quic_cert_file, p.quic_cert_file);
2677 assert_eq!(p2.quic_key_file, p.quic_key_file);
2678 }
2679
2680 #[test]
2681 fn transport_quic_requires_cert_and_key() {
2682 let mut p = pool();
2683 p.transport = Some(Transport::Quic);
2684 p.apply_defaults();
2685 let err = p.validate("p").unwrap_err();
2686 assert!(matches!(
2687 err,
2688 ConfError::BadServer {
2689 field: "quic_cert_file",
2690 ..
2691 }
2692 ));
2693 p.quic_cert_file = Some(std::path::PathBuf::from("/tmp/c.pem"));
2694 let err = p.validate("p").unwrap_err();
2695 assert!(matches!(
2696 err,
2697 ConfError::BadServer {
2698 field: "quic_key_file",
2699 ..
2700 }
2701 ));
2702 p.quic_key_file = Some(std::path::PathBuf::from("/tmp/k.pem"));
2703 assert!(p.validate("p").is_ok());
2704 }
2705
2706 #[test]
2707 fn transport_tcp_ignores_quic_files() {
2708 let mut p = pool();
2709 p.transport = Some(Transport::Tcp);
2710 p.quic_cert_file = Some(std::path::PathBuf::from("/tmp/c.pem"));
2714 p.apply_defaults();
2715 assert!(p.validate("p").is_ok());
2716 }
2717}