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/// Request for a bounded manual lifecycle transition run.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub struct ManualTransitionRunRequest {
293    pub bucket: String,
294    pub prefix: String,
295    pub tier: Option<String>,
296    pub dry_run: bool,
297    pub max_objects: u64,
298    pub max_duration_seconds: Option<u64>,
299}
300
301/// Response returned by the manual lifecycle transition endpoint.
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct ManualTransitionRunResponse {
304    pub state: String,
305    pub mode: String,
306    pub job_id: Option<String>,
307    pub status_endpoint: Option<String>,
308    pub report: ManualTransitionRunReport,
309}
310
311/// Aggregate report for a manual lifecycle transition run.
312#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
313pub struct ManualTransitionRunReport {
314    pub bucket: String,
315    pub prefix: String,
316    pub tier: Option<String>,
317    pub dry_run: bool,
318    pub lifecycle_config_found: bool,
319    pub scanned: u64,
320    pub eligible: u64,
321    pub enqueued: u64,
322    pub dry_run_eligible: u64,
323    pub skipped_not_transition: u64,
324    pub skipped_tier: u64,
325    pub skipped_delete_marker: u64,
326    pub skipped_directory: u64,
327    pub skipped_replication: u64,
328    #[serde(default)]
329    pub skipped_already_transitioned: u64,
330    pub skipped_already_in_flight: u64,
331    pub skipped_queue_full: u64,
332    pub skipped_queue_closed: u64,
333    pub skipped_queue_timeout: u64,
334    pub truncated_by_limit: bool,
335    #[serde(default)]
336    pub truncated_by_duration: bool,
337}
338
339// ==================== Per-type sub-configs ====================
340// These match the RustFS backend JSON format exactly.
341
342#[derive(Debug, Clone, Serialize, Deserialize, Default)]
343#[serde(default)]
344pub struct TierS3 {
345    pub name: String,
346    pub endpoint: String,
347    #[serde(rename = "accessKey")]
348    pub access_key: String,
349    #[serde(rename = "secretKey")]
350    pub secret_key: String,
351    pub bucket: String,
352    pub prefix: String,
353    pub region: String,
354    #[serde(rename = "storageClass")]
355    pub storage_class: String,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize, Default)]
359#[serde(default)]
360pub struct TierRustFS {
361    pub name: String,
362    pub endpoint: String,
363    #[serde(rename = "accessKey")]
364    pub access_key: String,
365    #[serde(rename = "secretKey")]
366    pub secret_key: String,
367    pub bucket: String,
368    pub prefix: String,
369    pub region: String,
370    #[serde(rename = "storageClass")]
371    pub storage_class: String,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize, Default)]
375#[serde(default)]
376pub struct TierMinIO {
377    pub name: String,
378    pub endpoint: String,
379    #[serde(rename = "accessKey")]
380    pub access_key: String,
381    #[serde(rename = "secretKey")]
382    pub secret_key: String,
383    pub bucket: String,
384    pub prefix: String,
385    pub region: String,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, Default)]
389#[serde(default)]
390pub struct TierAliyun {
391    pub name: String,
392    pub endpoint: String,
393    #[serde(rename = "accessKey")]
394    pub access_key: String,
395    #[serde(rename = "secretKey")]
396    pub secret_key: String,
397    pub bucket: String,
398    pub prefix: String,
399    pub region: String,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize, Default)]
403#[serde(default)]
404pub struct TierTencent {
405    pub name: String,
406    pub endpoint: String,
407    #[serde(rename = "accessKey")]
408    pub access_key: String,
409    #[serde(rename = "secretKey")]
410    pub secret_key: String,
411    pub bucket: String,
412    pub prefix: String,
413    pub region: String,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize, Default)]
417#[serde(default)]
418pub struct TierHuaweicloud {
419    pub name: String,
420    pub endpoint: String,
421    #[serde(rename = "accessKey")]
422    pub access_key: String,
423    #[serde(rename = "secretKey")]
424    pub secret_key: String,
425    pub bucket: String,
426    pub prefix: String,
427    pub region: String,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize, Default)]
431#[serde(default)]
432pub struct TierAzure {
433    pub name: String,
434    pub endpoint: String,
435    #[serde(rename = "accessKey")]
436    pub access_key: String,
437    #[serde(rename = "secretKey")]
438    pub secret_key: String,
439    pub bucket: String,
440    pub prefix: String,
441    pub region: String,
442    #[serde(rename = "storageClass")]
443    pub storage_class: String,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize, Default)]
447#[serde(default)]
448pub struct TierGCS {
449    pub name: String,
450    pub endpoint: String,
451    #[serde(rename = "creds")]
452    pub creds: String,
453    pub bucket: String,
454    pub prefix: String,
455    pub region: String,
456    #[serde(rename = "storageClass")]
457    pub storage_class: String,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize, Default)]
461#[serde(default)]
462pub struct TierR2 {
463    pub name: String,
464    pub endpoint: String,
465    #[serde(rename = "accessKey")]
466    pub access_key: String,
467    #[serde(rename = "secretKey")]
468    pub secret_key: String,
469    pub bucket: String,
470    pub prefix: String,
471    pub region: String,
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn test_tier_type_display() {
480        assert_eq!(TierType::S3.to_string(), "S3");
481        assert_eq!(TierType::RustFS.to_string(), "RustFS");
482        assert_eq!(TierType::MinIO.to_string(), "MinIO");
483        assert_eq!(TierType::Azure.to_string(), "Azure");
484        assert_eq!(TierType::GCS.to_string(), "GCS");
485        assert_eq!(TierType::R2.to_string(), "R2");
486    }
487
488    #[test]
489    fn test_tier_type_from_str() {
490        assert_eq!("s3".parse::<TierType>().unwrap(), TierType::S3);
491        assert_eq!("rustfs".parse::<TierType>().unwrap(), TierType::RustFS);
492        assert_eq!("MINIO".parse::<TierType>().unwrap(), TierType::MinIO);
493        assert_eq!("Azure".parse::<TierType>().unwrap(), TierType::Azure);
494        assert!("invalid".parse::<TierType>().is_err());
495    }
496
497    #[test]
498    fn test_tier_config_serialization_s3() {
499        let config = TierConfig {
500            tier_type: TierType::S3,
501            name: "WARM".to_string(),
502            s3: Some(TierS3 {
503                name: "WARM".to_string(),
504                endpoint: "https://s3.amazonaws.com".to_string(),
505                access_key: "AKID".to_string(),
506                secret_key: "REDACTED".to_string(),
507                bucket: "warm-bucket".to_string(),
508                prefix: "tier/".to_string(),
509                region: "us-east-1".to_string(),
510                storage_class: "STANDARD_IA".to_string(),
511            }),
512            ..Default::default()
513        };
514
515        let json = serde_json::to_string(&config).unwrap();
516        assert!(json.contains(r#""type":"s3""#));
517        assert!(json.contains("warm-bucket"));
518
519        let decoded: TierConfig = serde_json::from_str(&json).unwrap();
520        assert_eq!(decoded.tier_type, TierType::S3);
521        assert_eq!(decoded.tier_name(), "WARM");
522        assert_eq!(decoded.bucket(), "warm-bucket");
523    }
524
525    #[test]
526    fn test_tier_config_deserialization_from_backend() {
527        // Simulates the JSON format returned by the RustFS admin API
528        let json = r#"{"type":"rustfs","rustfs":{"name":"ARCHIVE","endpoint":"http://remote:9000","accessKey":"admin","secretKey":"REDACTED","bucket":"archive","prefix":"","region":""}}"#;
529        let config: TierConfig = serde_json::from_str(json).unwrap();
530        assert_eq!(config.tier_type, TierType::RustFS);
531        assert_eq!(config.tier_name(), "ARCHIVE");
532        assert_eq!(config.endpoint(), "http://remote:9000");
533        assert_eq!(config.bucket(), "archive");
534    }
535
536    #[test]
537    fn test_tier_creds_serialization() {
538        let creds = TierCreds {
539            access_key: "newkey".to_string(),
540            secret_key: "newsecret".to_string(),
541        };
542
543        let json = serde_json::to_string(&creds).unwrap();
544        assert!(json.contains("accessKey"));
545        assert!(json.contains("secretKey"));
546
547        let decoded: TierCreds = serde_json::from_str(&json).unwrap();
548        assert_eq!(decoded.access_key, "newkey");
549    }
550}