Skip to main content

crabka_replicator/
config.rs

1//! Replicator configuration model (clusters / flows / policies).
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::ReplicatorError;
8
9/// Top-level replicator configuration.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ReplicatorConfig {
12    /// Named cluster definitions keyed by alias.
13    pub clusters: BTreeMap<String, ClusterConfig>,
14    /// Directional replication flows.
15    pub flows: Vec<FlowConfig>,
16    /// Residency / compliance policies.
17    #[serde(default)]
18    pub policies: Vec<PolicyConfig>,
19}
20
21/// Configuration for a single Kafka cluster.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ClusterConfig {
24    /// Bootstrap server address (host:port).
25    pub bootstrap: String,
26    /// Logical region name (e.g. `"us"`, `"eu"`).
27    pub region: String,
28    /// Availability / compliance zones this cluster belongs to.
29    #[serde(default)]
30    pub zones: Vec<String>,
31}
32
33/// A directional replication flow from one cluster to another.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct FlowConfig {
36    /// Source cluster alias.
37    pub from: String,
38    /// Target cluster alias.
39    pub to: String,
40    /// Topic selector (include / exclude glob patterns).
41    #[serde(default)]
42    pub topics: Selectors,
43    /// Consumer-group selector (include / exclude glob patterns).
44    #[serde(default)]
45    pub groups: Selectors,
46    /// How replicated topics are named on the target cluster.
47    #[serde(default)]
48    pub naming: NamingPolicy,
49    /// Delivery guarantee.
50    #[serde(default)]
51    pub delivery: Delivery,
52}
53
54/// Include / exclude glob-pattern selector lists.
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct Selectors {
57    /// Patterns that must match.
58    #[serde(default)]
59    pub include: Vec<String>,
60    /// Patterns that cause a match to be skipped.
61    #[serde(default)]
62    pub exclude: Vec<String>,
63}
64
65/// How replicated topic names are derived on the target cluster.
66#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "kebab-case")]
68pub enum NamingPolicy {
69    /// Prefix topic name with the source cluster alias (`MirrorMaker` 2 default).
70    #[default]
71    Default,
72    /// Keep the original topic name unchanged.
73    Identity,
74}
75
76/// Delivery guarantee for a flow.
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum Delivery {
80    /// At-least-once delivery (supported from Slice 1).
81    #[default]
82    AtLeastOnce,
83    /// Exactly-once delivery (planned for Slice 3; rejected by Slice 1 validation).
84    ExactlyOnce,
85}
86
87/// A residency / compliance policy that constrains where topic data may land.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct PolicyConfig {
90    /// Human-readable policy name.
91    pub name: String,
92    /// Topics this policy applies to.
93    #[serde(default)]
94    pub topics: Vec<String>,
95    /// Zone residency constraints.
96    #[serde(default)]
97    pub residency: Option<Residency>,
98}
99
100/// Zone-based data-residency constraints.
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct Residency {
103    /// Zones to which data is permitted to flow.
104    #[serde(default)]
105    pub allow_zones: Vec<String>,
106    /// Zones to which data must not flow.
107    #[serde(default)]
108    pub deny_zones: Vec<String>,
109}
110
111impl ReplicatorConfig {
112    /// Parse a [`ReplicatorConfig`] from a YAML string.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`ReplicatorError::Config`] if the YAML is malformed or fails
117    /// to deserialize.
118    pub fn from_yaml(s: &str) -> Result<Self, ReplicatorError> {
119        serde_yaml::from_str(s).map_err(|e| ReplicatorError::Config(e.to_string()))
120    }
121
122    /// Validate a parsed config against Slice-1 constraints.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`ReplicatorError::Config`] if:
127    /// - A flow references an unknown cluster alias.
128    /// - A flow requests `exactly-once` delivery (not supported until Slice 3).
129    pub fn validate(&self) -> Result<(), ReplicatorError> {
130        for f in &self.flows {
131            if !self.clusters.contains_key(&f.from) {
132                return Err(ReplicatorError::Config(format!(
133                    "flow.from unknown cluster `{}`",
134                    f.from
135                )));
136            }
137            if !self.clusters.contains_key(&f.to) {
138                return Err(ReplicatorError::Config(format!(
139                    "flow.to unknown cluster `{}`",
140                    f.to
141                )));
142            }
143            if f.delivery == Delivery::ExactlyOnce {
144                return Err(ReplicatorError::Config(
145                    "delivery `exactly-once` is not supported until Slice 3; use `at-least-once`"
146                        .into(),
147                ));
148            }
149        }
150        Ok(())
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use assert2::assert;
157
158    use super::*;
159
160    const YAML: &str = r#"
161clusters:
162  us-east: { bootstrap: "127.0.0.1:9092", region: us, zones: [us] }
163  eu-west: { bootstrap: "127.0.0.1:9093", region: eu, zones: [eu, gdpr] }
164flows:
165  - from: us-east
166    to: eu-west
167    topics: { include: ["orders", "telemetry.*"], exclude: ["*.internal"] }
168    groups: { include: ["analytics-*"] }
169    naming: default
170    delivery: at-least-once
171policies:
172  - name: keep-pii-in-eu
173    topics: ["customers"]
174    residency: { allow_zones: [gdpr] }
175"#;
176
177    #[test]
178    fn parses_and_validates() {
179        let cfg = ReplicatorConfig::from_yaml(YAML).unwrap();
180        assert!(cfg.clusters.len() == 2);
181        assert!(cfg.clusters["eu-west"].zones == vec!["eu".to_string(), "gdpr".to_string()]);
182        assert!(cfg.flows[0].naming == NamingPolicy::Default);
183        assert!(cfg.flows[0].delivery == Delivery::AtLeastOnce);
184        cfg.validate().unwrap();
185    }
186
187    #[test]
188    fn rejects_eos_in_slice1() {
189        let y = YAML.replace("delivery: at-least-once", "delivery: exactly-once");
190        let cfg = ReplicatorConfig::from_yaml(&y).unwrap();
191        let err = cfg.validate().unwrap_err();
192        assert!(format!("{err}").contains("exactly-once"), "got: {err}");
193    }
194
195    #[test]
196    fn rejects_unknown_cluster_ref() {
197        let y = YAML.replace("to: eu-west", "to: nowhere");
198        let cfg = ReplicatorConfig::from_yaml(&y).unwrap();
199        assert!(cfg.validate().is_err());
200    }
201}