Skip to main content

rc_core/
replication.rs

1//! Bucket replication configuration types
2//!
3//! Domain types for S3 bucket replication configuration and
4//! RustFS admin API remote target management.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9// ==================== S3 Replication Config Types ====================
10
11/// Full replication configuration for a bucket (S3 API)
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ReplicationConfiguration {
14    /// Role ARN or empty for per-rule destination ARNs
15    #[serde(default)]
16    pub role: String,
17
18    /// Replication rules
19    pub rules: Vec<ReplicationRule>,
20}
21
22/// A single replication rule
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct ReplicationRule {
26    /// Rule identifier
27    pub id: String,
28
29    /// Rule priority (higher = more important)
30    pub priority: i32,
31
32    /// Whether the rule is enabled or disabled
33    pub status: ReplicationRuleStatus,
34
35    /// Key prefix filter
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub prefix: Option<String>,
38
39    /// Tag-based filter
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub tags: Option<std::collections::HashMap<String, String>>,
42
43    /// Destination bucket ARN and optional storage class
44    pub destination: ReplicationDestination,
45
46    /// Whether to replicate delete markers
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub delete_marker_replication: Option<bool>,
49
50    /// Whether to replicate existing objects
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub existing_object_replication: Option<bool>,
53
54    /// Whether to replicate version deletes
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub delete_replication: Option<bool>,
57}
58
59/// Rule status
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61pub enum ReplicationRuleStatus {
62    Enabled,
63    Disabled,
64}
65
66impl fmt::Display for ReplicationRuleStatus {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            ReplicationRuleStatus::Enabled => write!(f, "Enabled"),
70            ReplicationRuleStatus::Disabled => write!(f, "Disabled"),
71        }
72    }
73}
74
75impl std::str::FromStr for ReplicationRuleStatus {
76    type Err = String;
77
78    fn from_str(s: &str) -> Result<Self, Self::Err> {
79        match s.to_lowercase().as_str() {
80            "enabled" => Ok(ReplicationRuleStatus::Enabled),
81            "disabled" => Ok(ReplicationRuleStatus::Disabled),
82            _ => Err(format!("Invalid replication rule status: {s}")),
83        }
84    }
85}
86
87/// Replication destination
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct ReplicationDestination {
91    /// Destination bucket ARN
92    pub bucket_arn: String,
93
94    /// Optional storage class override at destination
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub storage_class: Option<String>,
97}
98
99// ==================== Admin API Remote Target Types ====================
100
101/// Remote bucket target for replication (matches RustFS admin API JSON format)
102#[derive(Debug, Clone, Serialize, Deserialize, Default)]
103pub struct BucketTarget {
104    #[serde(rename = "sourcebucket", default)]
105    pub source_bucket: String,
106
107    #[serde(default)]
108    pub endpoint: String,
109
110    #[serde(default)]
111    pub credentials: Option<BucketTargetCredentials>,
112
113    #[serde(rename = "targetbucket", default)]
114    pub target_bucket: String,
115
116    #[serde(default)]
117    pub secure: bool,
118
119    #[serde(
120        rename = "skipTlsVerify",
121        default,
122        skip_serializing_if = "Option::is_none"
123    )]
124    pub skip_tls_verify: Option<bool>,
125
126    #[serde(rename = "caCertPem", default, skip_serializing_if = "Option::is_none")]
127    pub ca_cert_pem: Option<String>,
128
129    #[serde(default)]
130    pub path: String,
131
132    #[serde(default)]
133    pub api: String,
134
135    #[serde(default)]
136    pub arn: String,
137
138    #[serde(rename = "type", default)]
139    pub target_type: String,
140
141    #[serde(default)]
142    pub region: String,
143
144    #[serde(alias = "bandwidth", default)]
145    pub bandwidth_limit: i64,
146
147    #[serde(rename = "replicationSync", default)]
148    pub replication_sync: bool,
149
150    #[serde(default)]
151    pub storage_class: String,
152
153    #[serde(rename = "healthCheckDuration", default)]
154    pub health_check_duration: u64,
155
156    #[serde(rename = "disableProxy", default)]
157    pub disable_proxy: bool,
158
159    #[serde(rename = "isOnline", default)]
160    pub online: bool,
161}
162
163/// Credentials for a remote bucket target
164#[derive(Debug, Clone, Serialize, Deserialize, Default)]
165pub struct BucketTargetCredentials {
166    #[serde(rename = "accessKey")]
167    pub access_key: String,
168    #[serde(rename = "secretKey")]
169    pub secret_key: String,
170}
171
172impl fmt::Display for ReplicationRule {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        write!(
175            f,
176            "{} (priority={}, {})",
177            self.id, self.priority, self.status
178        )
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_replication_rule_status_display() {
188        assert_eq!(ReplicationRuleStatus::Enabled.to_string(), "Enabled");
189        assert_eq!(ReplicationRuleStatus::Disabled.to_string(), "Disabled");
190    }
191
192    #[test]
193    fn test_replication_rule_status_from_str() {
194        assert_eq!(
195            "enabled".parse::<ReplicationRuleStatus>().unwrap(),
196            ReplicationRuleStatus::Enabled
197        );
198        assert!("invalid".parse::<ReplicationRuleStatus>().is_err());
199    }
200
201    #[test]
202    fn test_replication_configuration_serialization() {
203        let config = ReplicationConfiguration {
204            role: "arn:aws:iam::123456789:role/replication".to_string(),
205            rules: vec![ReplicationRule {
206                id: "rule-1".to_string(),
207                priority: 1,
208                status: ReplicationRuleStatus::Enabled,
209                prefix: Some("data/".to_string()),
210                tags: None,
211                destination: ReplicationDestination {
212                    bucket_arn: "arn:aws:s3:::dest-bucket".to_string(),
213                    storage_class: None,
214                },
215                delete_marker_replication: Some(true),
216                existing_object_replication: Some(true),
217                delete_replication: None,
218            }],
219        };
220
221        let json = serde_json::to_string_pretty(&config).unwrap();
222        let decoded: ReplicationConfiguration = serde_json::from_str(&json).unwrap();
223        assert_eq!(decoded.rules.len(), 1);
224        assert_eq!(decoded.rules[0].id, "rule-1");
225        assert_eq!(decoded.rules[0].priority, 1);
226    }
227
228    #[test]
229    fn test_bucket_target_serialization() {
230        let target = BucketTarget {
231            source_bucket: "my-bucket".to_string(),
232            endpoint: "http://remote:9000".to_string(),
233            credentials: Some(BucketTargetCredentials {
234                access_key: "admin".to_string(),
235                secret_key: "secret".to_string(),
236            }),
237            target_bucket: "dest-bucket".to_string(),
238            secure: false,
239            skip_tls_verify: Some(true),
240            target_type: "replication".to_string(),
241            region: "us-east-1".to_string(),
242            replication_sync: true,
243            ..Default::default()
244        };
245
246        let json = serde_json::to_string(&target).unwrap();
247        assert!(json.contains("sourcebucket"));
248        assert!(json.contains("targetbucket"));
249        assert!(json.contains("replicationSync"));
250        assert!(json.contains("skipTlsVerify"));
251
252        let decoded: BucketTarget = serde_json::from_str(&json).unwrap();
253        assert_eq!(decoded.source_bucket, "my-bucket");
254        assert_eq!(decoded.target_bucket, "dest-bucket");
255        assert!(decoded.replication_sync);
256        assert_eq!(decoded.skip_tls_verify, Some(true));
257    }
258
259    #[test]
260    fn test_bucket_target_deserialization_from_backend() {
261        let json = r#"{"sourcebucket":"src","endpoint":"http://host:9000","credentials":{"accessKey":"ak","secretKey":"sk"},"targetbucket":"dst","secure":false,"path":"","api":"","arn":"arn:rustfs:replication::id:dst","type":"replication","region":"","bandwidth":0,"replicationSync":false,"storage_class":"","healthCheckDuration":0,"disableProxy":false,"isOnline":true}"#;
262        let target: BucketTarget = serde_json::from_str(json).unwrap();
263        assert_eq!(target.source_bucket, "src");
264        assert_eq!(target.target_bucket, "dst");
265        assert!(target.online);
266        assert_eq!(target.target_type, "replication");
267    }
268
269    #[test]
270    fn test_bucket_target_serialization_includes_ca_cert_pem_content() {
271        let pem = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n";
272        let target = BucketTarget {
273            source_bucket: "my-bucket".to_string(),
274            endpoint: "remote:9000".to_string(),
275            target_bucket: "dest-bucket".to_string(),
276            secure: true,
277            skip_tls_verify: Some(false),
278            ca_cert_pem: Some(pem.to_string()),
279            target_type: "replication".to_string(),
280            ..Default::default()
281        };
282
283        let json = serde_json::to_string(&target).unwrap();
284        assert!(json.contains("\"skipTlsVerify\":false"));
285        assert!(json.contains("caCertPem"));
286        assert!(!json.contains("ca.pem"));
287    }
288}