Skip to main content

rc_core/admin/
tier.rs

1//! Tier configuration types for remote storage tiering
2//!
3//! These types match the RustFS admin API JSON format for tier management.
4//! Tiers are used by lifecycle transition rules to move objects to
5//! remote storage backends.
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10/// Supported remote storage tier types
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum TierType {
13    #[serde(rename = "s3")]
14    S3,
15    #[serde(rename = "rustfs")]
16    RustFS,
17    #[serde(rename = "minio")]
18    MinIO,
19    #[serde(rename = "aliyun")]
20    Aliyun,
21    #[serde(rename = "tencent")]
22    Tencent,
23    #[serde(rename = "huaweicloud")]
24    Huaweicloud,
25    #[serde(rename = "azure")]
26    Azure,
27    #[serde(rename = "gcs")]
28    GCS,
29    #[serde(rename = "r2")]
30    R2,
31}
32
33impl fmt::Display for TierType {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            TierType::S3 => write!(f, "S3"),
37            TierType::RustFS => write!(f, "RustFS"),
38            TierType::MinIO => write!(f, "MinIO"),
39            TierType::Aliyun => write!(f, "Aliyun"),
40            TierType::Tencent => write!(f, "Tencent"),
41            TierType::Huaweicloud => write!(f, "Huaweicloud"),
42            TierType::Azure => write!(f, "Azure"),
43            TierType::GCS => write!(f, "GCS"),
44            TierType::R2 => write!(f, "R2"),
45        }
46    }
47}
48
49impl std::str::FromStr for TierType {
50    type Err = String;
51
52    fn from_str(s: &str) -> Result<Self, Self::Err> {
53        match s.to_lowercase().as_str() {
54            "s3" => Ok(TierType::S3),
55            "rustfs" => Ok(TierType::RustFS),
56            "minio" => Ok(TierType::MinIO),
57            "aliyun" => Ok(TierType::Aliyun),
58            "tencent" => Ok(TierType::Tencent),
59            "huaweicloud" => Ok(TierType::Huaweicloud),
60            "azure" => Ok(TierType::Azure),
61            "gcs" => Ok(TierType::GCS),
62            "r2" => Ok(TierType::R2),
63            _ => Err(format!(
64                "Invalid tier type: {s}. Valid types: s3, rustfs, minio, aliyun, tencent, huaweicloud, azure, gcs, r2"
65            )),
66        }
67    }
68}
69
70/// Tier configuration matching the RustFS admin API format.
71///
72/// The backend uses a polymorphic structure: the `type` field selects which
73/// sub-config (s3, rustfs, minio, etc.) is active. The tier name lives
74/// inside the sub-config.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(default)]
77pub struct TierConfig {
78    #[serde(rename = "type")]
79    pub tier_type: TierType,
80
81    /// Tier name — extracted from the active sub-config on the backend side.
82    /// Populated by the CLI when building a TierConfig for add operations.
83    #[serde(skip)]
84    pub name: String,
85
86    #[serde(rename = "s3", skip_serializing_if = "Option::is_none")]
87    pub s3: Option<TierS3>,
88    #[serde(rename = "rustfs", skip_serializing_if = "Option::is_none")]
89    pub rustfs: Option<TierRustFS>,
90    #[serde(rename = "minio", skip_serializing_if = "Option::is_none")]
91    pub minio: Option<TierMinIO>,
92    #[serde(rename = "aliyun", skip_serializing_if = "Option::is_none")]
93    pub aliyun: Option<TierAliyun>,
94    #[serde(rename = "tencent", skip_serializing_if = "Option::is_none")]
95    pub tencent: Option<TierTencent>,
96    #[serde(rename = "huaweicloud", skip_serializing_if = "Option::is_none")]
97    pub huaweicloud: Option<TierHuaweicloud>,
98    #[serde(rename = "azure", skip_serializing_if = "Option::is_none")]
99    pub azure: Option<TierAzure>,
100    #[serde(rename = "gcs", skip_serializing_if = "Option::is_none")]
101    pub gcs: Option<TierGCS>,
102    #[serde(rename = "r2", skip_serializing_if = "Option::is_none")]
103    pub r2: Option<TierR2>,
104}
105
106impl Default for TierConfig {
107    fn default() -> Self {
108        Self {
109            tier_type: TierType::S3,
110            name: String::new(),
111            s3: None,
112            rustfs: None,
113            minio: None,
114            aliyun: None,
115            tencent: None,
116            huaweicloud: None,
117            azure: None,
118            gcs: None,
119            r2: None,
120        }
121    }
122}
123
124impl TierConfig {
125    /// Get the tier name from the active sub-config
126    pub fn tier_name(&self) -> &str {
127        if !self.name.is_empty() {
128            return &self.name;
129        }
130        match self.tier_type {
131            TierType::S3 => self.s3.as_ref().map(|c| c.name.as_str()).unwrap_or(""),
132            TierType::RustFS => self.rustfs.as_ref().map(|c| c.name.as_str()).unwrap_or(""),
133            TierType::MinIO => self.minio.as_ref().map(|c| c.name.as_str()).unwrap_or(""),
134            TierType::Aliyun => self.aliyun.as_ref().map(|c| c.name.as_str()).unwrap_or(""),
135            TierType::Tencent => self.tencent.as_ref().map(|c| c.name.as_str()).unwrap_or(""),
136            TierType::Huaweicloud => self
137                .huaweicloud
138                .as_ref()
139                .map(|c| c.name.as_str())
140                .unwrap_or(""),
141            TierType::Azure => self.azure.as_ref().map(|c| c.name.as_str()).unwrap_or(""),
142            TierType::GCS => self.gcs.as_ref().map(|c| c.name.as_str()).unwrap_or(""),
143            TierType::R2 => self.r2.as_ref().map(|c| c.name.as_str()).unwrap_or(""),
144        }
145    }
146
147    /// Get the endpoint from the active sub-config
148    pub fn endpoint(&self) -> &str {
149        match self.tier_type {
150            TierType::S3 => self.s3.as_ref().map(|c| c.endpoint.as_str()).unwrap_or(""),
151            TierType::RustFS => self
152                .rustfs
153                .as_ref()
154                .map(|c| c.endpoint.as_str())
155                .unwrap_or(""),
156            TierType::MinIO => self
157                .minio
158                .as_ref()
159                .map(|c| c.endpoint.as_str())
160                .unwrap_or(""),
161            TierType::Aliyun => self
162                .aliyun
163                .as_ref()
164                .map(|c| c.endpoint.as_str())
165                .unwrap_or(""),
166            TierType::Tencent => self
167                .tencent
168                .as_ref()
169                .map(|c| c.endpoint.as_str())
170                .unwrap_or(""),
171            TierType::Huaweicloud => self
172                .huaweicloud
173                .as_ref()
174                .map(|c| c.endpoint.as_str())
175                .unwrap_or(""),
176            TierType::Azure => self
177                .azure
178                .as_ref()
179                .map(|c| c.endpoint.as_str())
180                .unwrap_or(""),
181            TierType::GCS => self.gcs.as_ref().map(|c| c.endpoint.as_str()).unwrap_or(""),
182            TierType::R2 => self.r2.as_ref().map(|c| c.endpoint.as_str()).unwrap_or(""),
183        }
184    }
185
186    /// Get the bucket from the active sub-config
187    pub fn bucket(&self) -> &str {
188        match self.tier_type {
189            TierType::S3 => self.s3.as_ref().map(|c| c.bucket.as_str()).unwrap_or(""),
190            TierType::RustFS => self
191                .rustfs
192                .as_ref()
193                .map(|c| c.bucket.as_str())
194                .unwrap_or(""),
195            TierType::MinIO => self.minio.as_ref().map(|c| c.bucket.as_str()).unwrap_or(""),
196            TierType::Aliyun => self
197                .aliyun
198                .as_ref()
199                .map(|c| c.bucket.as_str())
200                .unwrap_or(""),
201            TierType::Tencent => self
202                .tencent
203                .as_ref()
204                .map(|c| c.bucket.as_str())
205                .unwrap_or(""),
206            TierType::Huaweicloud => self
207                .huaweicloud
208                .as_ref()
209                .map(|c| c.bucket.as_str())
210                .unwrap_or(""),
211            TierType::Azure => self.azure.as_ref().map(|c| c.bucket.as_str()).unwrap_or(""),
212            TierType::GCS => self.gcs.as_ref().map(|c| c.bucket.as_str()).unwrap_or(""),
213            TierType::R2 => self.r2.as_ref().map(|c| c.bucket.as_str()).unwrap_or(""),
214        }
215    }
216
217    /// Get the prefix from the active sub-config
218    pub fn prefix(&self) -> &str {
219        match self.tier_type {
220            TierType::S3 => self.s3.as_ref().map(|c| c.prefix.as_str()).unwrap_or(""),
221            TierType::RustFS => self
222                .rustfs
223                .as_ref()
224                .map(|c| c.prefix.as_str())
225                .unwrap_or(""),
226            TierType::MinIO => self.minio.as_ref().map(|c| c.prefix.as_str()).unwrap_or(""),
227            TierType::Aliyun => self
228                .aliyun
229                .as_ref()
230                .map(|c| c.prefix.as_str())
231                .unwrap_or(""),
232            TierType::Tencent => self
233                .tencent
234                .as_ref()
235                .map(|c| c.prefix.as_str())
236                .unwrap_or(""),
237            TierType::Huaweicloud => self
238                .huaweicloud
239                .as_ref()
240                .map(|c| c.prefix.as_str())
241                .unwrap_or(""),
242            TierType::Azure => self.azure.as_ref().map(|c| c.prefix.as_str()).unwrap_or(""),
243            TierType::GCS => self.gcs.as_ref().map(|c| c.prefix.as_str()).unwrap_or(""),
244            TierType::R2 => self.r2.as_ref().map(|c| c.prefix.as_str()).unwrap_or(""),
245        }
246    }
247
248    /// Get the region from the active sub-config
249    pub fn region(&self) -> &str {
250        match self.tier_type {
251            TierType::S3 => self.s3.as_ref().map(|c| c.region.as_str()).unwrap_or(""),
252            TierType::RustFS => self
253                .rustfs
254                .as_ref()
255                .map(|c| c.region.as_str())
256                .unwrap_or(""),
257            TierType::MinIO => self.minio.as_ref().map(|c| c.region.as_str()).unwrap_or(""),
258            TierType::Aliyun => self
259                .aliyun
260                .as_ref()
261                .map(|c| c.region.as_str())
262                .unwrap_or(""),
263            TierType::Tencent => self
264                .tencent
265                .as_ref()
266                .map(|c| c.region.as_str())
267                .unwrap_or(""),
268            TierType::Huaweicloud => self
269                .huaweicloud
270                .as_ref()
271                .map(|c| c.region.as_str())
272                .unwrap_or(""),
273            TierType::Azure => self.azure.as_ref().map(|c| c.region.as_str()).unwrap_or(""),
274            TierType::GCS => self.gcs.as_ref().map(|c| c.region.as_str()).unwrap_or(""),
275            TierType::R2 => self.r2.as_ref().map(|c| c.region.as_str()).unwrap_or(""),
276        }
277    }
278}
279
280/// Credentials for updating a tier
281#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282#[serde(default)]
283pub struct TierCreds {
284    #[serde(rename = "accessKey")]
285    pub access_key: String,
286    #[serde(rename = "secretKey")]
287    pub secret_key: String,
288}
289
290// ==================== Per-type sub-configs ====================
291// These match the RustFS backend JSON format exactly.
292
293#[derive(Debug, Clone, Serialize, Deserialize, Default)]
294#[serde(default)]
295pub struct TierS3 {
296    pub name: String,
297    pub endpoint: String,
298    #[serde(rename = "accessKey")]
299    pub access_key: String,
300    #[serde(rename = "secretKey")]
301    pub secret_key: String,
302    pub bucket: String,
303    pub prefix: String,
304    pub region: String,
305    #[serde(rename = "storageClass")]
306    pub storage_class: String,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, Default)]
310#[serde(default)]
311pub struct TierRustFS {
312    pub name: String,
313    pub endpoint: String,
314    #[serde(rename = "accessKey")]
315    pub access_key: String,
316    #[serde(rename = "secretKey")]
317    pub secret_key: String,
318    pub bucket: String,
319    pub prefix: String,
320    pub region: String,
321    #[serde(rename = "storageClass")]
322    pub storage_class: String,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, Default)]
326#[serde(default)]
327pub struct TierMinIO {
328    pub name: String,
329    pub endpoint: String,
330    #[serde(rename = "accessKey")]
331    pub access_key: String,
332    #[serde(rename = "secretKey")]
333    pub secret_key: String,
334    pub bucket: String,
335    pub prefix: String,
336    pub region: String,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize, Default)]
340#[serde(default)]
341pub struct TierAliyun {
342    pub name: String,
343    pub endpoint: String,
344    #[serde(rename = "accessKey")]
345    pub access_key: String,
346    #[serde(rename = "secretKey")]
347    pub secret_key: String,
348    pub bucket: String,
349    pub prefix: String,
350    pub region: String,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, Default)]
354#[serde(default)]
355pub struct TierTencent {
356    pub name: String,
357    pub endpoint: String,
358    #[serde(rename = "accessKey")]
359    pub access_key: String,
360    #[serde(rename = "secretKey")]
361    pub secret_key: String,
362    pub bucket: String,
363    pub prefix: String,
364    pub region: String,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, Default)]
368#[serde(default)]
369pub struct TierHuaweicloud {
370    pub name: String,
371    pub endpoint: String,
372    #[serde(rename = "accessKey")]
373    pub access_key: String,
374    #[serde(rename = "secretKey")]
375    pub secret_key: String,
376    pub bucket: String,
377    pub prefix: String,
378    pub region: String,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, Default)]
382#[serde(default)]
383pub struct TierAzure {
384    pub name: String,
385    pub endpoint: String,
386    #[serde(rename = "accessKey")]
387    pub access_key: String,
388    #[serde(rename = "secretKey")]
389    pub secret_key: String,
390    pub bucket: String,
391    pub prefix: String,
392    pub region: String,
393    #[serde(rename = "storageClass")]
394    pub storage_class: String,
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize, Default)]
398#[serde(default)]
399pub struct TierGCS {
400    pub name: String,
401    pub endpoint: String,
402    #[serde(rename = "creds")]
403    pub creds: String,
404    pub bucket: String,
405    pub prefix: String,
406    pub region: String,
407    #[serde(rename = "storageClass")]
408    pub storage_class: String,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, Default)]
412#[serde(default)]
413pub struct TierR2 {
414    pub name: String,
415    pub endpoint: String,
416    #[serde(rename = "accessKey")]
417    pub access_key: String,
418    #[serde(rename = "secretKey")]
419    pub secret_key: String,
420    pub bucket: String,
421    pub prefix: String,
422    pub region: String,
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn test_tier_type_display() {
431        assert_eq!(TierType::S3.to_string(), "S3");
432        assert_eq!(TierType::RustFS.to_string(), "RustFS");
433        assert_eq!(TierType::MinIO.to_string(), "MinIO");
434        assert_eq!(TierType::Azure.to_string(), "Azure");
435        assert_eq!(TierType::GCS.to_string(), "GCS");
436        assert_eq!(TierType::R2.to_string(), "R2");
437    }
438
439    #[test]
440    fn test_tier_type_from_str() {
441        assert_eq!("s3".parse::<TierType>().unwrap(), TierType::S3);
442        assert_eq!("rustfs".parse::<TierType>().unwrap(), TierType::RustFS);
443        assert_eq!("MINIO".parse::<TierType>().unwrap(), TierType::MinIO);
444        assert_eq!("Azure".parse::<TierType>().unwrap(), TierType::Azure);
445        assert!("invalid".parse::<TierType>().is_err());
446    }
447
448    #[test]
449    fn test_tier_config_serialization_s3() {
450        let config = TierConfig {
451            tier_type: TierType::S3,
452            name: "WARM".to_string(),
453            s3: Some(TierS3 {
454                name: "WARM".to_string(),
455                endpoint: "https://s3.amazonaws.com".to_string(),
456                access_key: "AKID".to_string(),
457                secret_key: "REDACTED".to_string(),
458                bucket: "warm-bucket".to_string(),
459                prefix: "tier/".to_string(),
460                region: "us-east-1".to_string(),
461                storage_class: "STANDARD_IA".to_string(),
462            }),
463            ..Default::default()
464        };
465
466        let json = serde_json::to_string(&config).unwrap();
467        assert!(json.contains(r#""type":"s3""#));
468        assert!(json.contains("warm-bucket"));
469
470        let decoded: TierConfig = serde_json::from_str(&json).unwrap();
471        assert_eq!(decoded.tier_type, TierType::S3);
472        assert_eq!(decoded.tier_name(), "WARM");
473        assert_eq!(decoded.bucket(), "warm-bucket");
474    }
475
476    #[test]
477    fn test_tier_config_deserialization_from_backend() {
478        // Simulates the JSON format returned by the RustFS admin API
479        let json = r#"{"type":"rustfs","rustfs":{"name":"ARCHIVE","endpoint":"http://remote:9000","accessKey":"admin","secretKey":"REDACTED","bucket":"archive","prefix":"","region":""}}"#;
480        let config: TierConfig = serde_json::from_str(json).unwrap();
481        assert_eq!(config.tier_type, TierType::RustFS);
482        assert_eq!(config.tier_name(), "ARCHIVE");
483        assert_eq!(config.endpoint(), "http://remote:9000");
484        assert_eq!(config.bucket(), "archive");
485    }
486
487    #[test]
488    fn test_tier_creds_serialization() {
489        let creds = TierCreds {
490            access_key: "newkey".to_string(),
491            secret_key: "newsecret".to_string(),
492        };
493
494        let json = serde_json::to_string(&creds).unwrap();
495        assert!(json.contains("accessKey"));
496        assert!(json.contains("secretKey"));
497
498        let decoded: TierCreds = serde_json::from_str(&json).unwrap();
499        assert_eq!(decoded.access_key, "newkey");
500    }
501}