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    #[serde(default)]
307    pub job_id: Option<String>,
308    #[serde(default)]
309    pub status_endpoint: Option<String>,
310    #[serde(default)]
311    pub cancel_endpoint: Option<String>,
312    pub report: ManualTransitionRunReport,
313}
314
315/// Response returned when inspecting or cancelling a durable manual transition job.
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct ManualTransitionJobResponse {
318    pub status: String,
319    pub mode: String,
320    pub job_id: String,
321    pub status_endpoint: String,
322    pub cancel_endpoint: String,
323    pub cancel_requested: bool,
324    pub bucket: String,
325    pub prefix: String,
326    pub tier: Option<String>,
327    pub dry_run: bool,
328    pub created_at_unix_nanos: i128,
329    pub updated_at_unix_nanos: i128,
330    pub completed_at_unix_nanos: Option<i128>,
331    pub report: ManualTransitionRunReport,
332    #[serde(default)]
333    pub queue_snapshot: ManualTransitionQueueSnapshot,
334    pub failure_reason: Option<String>,
335}
336
337/// Snapshot of transition worker pressure reported with manual transition jobs.
338#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
339pub struct ManualTransitionQueueSnapshot {
340    #[serde(default)]
341    pub queue_capacity: u64,
342    #[serde(default)]
343    pub queued: u64,
344    #[serde(default)]
345    pub active: u64,
346    #[serde(default)]
347    pub workers: u64,
348    #[serde(default)]
349    pub queue_full: u64,
350    #[serde(default)]
351    pub queue_send_timeout: u64,
352    #[serde(default)]
353    pub compensation_pending: u64,
354    #[serde(default)]
355    pub compensation_running: u64,
356}
357
358/// Aggregate report for a manual lifecycle transition run.
359#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
360pub struct ManualTransitionRunReport {
361    pub bucket: String,
362    pub prefix: String,
363    pub tier: Option<String>,
364    pub dry_run: bool,
365    pub lifecycle_config_found: bool,
366    pub scanned: u64,
367    pub eligible: u64,
368    pub enqueued: u64,
369    pub dry_run_eligible: u64,
370    pub skipped_not_transition: u64,
371    pub skipped_tier: u64,
372    pub skipped_delete_marker: u64,
373    pub skipped_directory: u64,
374    pub skipped_replication: u64,
375    #[serde(default)]
376    pub skipped_already_transitioned: u64,
377    pub skipped_already_in_flight: u64,
378    pub skipped_queue_full: u64,
379    pub skipped_queue_closed: u64,
380    pub skipped_queue_timeout: u64,
381    #[serde(default)]
382    pub transition_completed: u64,
383    #[serde(default)]
384    pub transition_failed: u64,
385    #[serde(default)]
386    pub tier_failure: u64,
387    pub truncated_by_limit: bool,
388    #[serde(default)]
389    pub truncated_by_duration: bool,
390    #[serde(default)]
391    pub cancelled: bool,
392    #[serde(default)]
393    pub continuation_token: Option<String>,
394}
395
396// ==================== Per-type sub-configs ====================
397// These match the RustFS backend JSON format exactly.
398
399#[derive(Debug, Clone, Serialize, Deserialize, Default)]
400#[serde(default)]
401pub struct TierS3 {
402    pub name: String,
403    pub endpoint: String,
404    #[serde(rename = "accessKey")]
405    pub access_key: String,
406    #[serde(rename = "secretKey")]
407    pub secret_key: String,
408    pub bucket: String,
409    pub prefix: String,
410    pub region: String,
411    #[serde(rename = "storageClass")]
412    pub storage_class: String,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, Default)]
416#[serde(default)]
417pub struct TierRustFS {
418    pub name: String,
419    pub endpoint: String,
420    #[serde(rename = "accessKey")]
421    pub access_key: String,
422    #[serde(rename = "secretKey")]
423    pub secret_key: String,
424    pub bucket: String,
425    pub prefix: String,
426    pub region: String,
427    #[serde(rename = "storageClass")]
428    pub storage_class: String,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, Default)]
432#[serde(default)]
433pub struct TierMinIO {
434    pub name: String,
435    pub endpoint: String,
436    #[serde(rename = "accessKey")]
437    pub access_key: String,
438    #[serde(rename = "secretKey")]
439    pub secret_key: String,
440    pub bucket: String,
441    pub prefix: String,
442    pub region: String,
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize, Default)]
446#[serde(default)]
447pub struct TierAliyun {
448    pub name: String,
449    pub endpoint: String,
450    #[serde(rename = "accessKey")]
451    pub access_key: String,
452    #[serde(rename = "secretKey")]
453    pub secret_key: String,
454    pub bucket: String,
455    pub prefix: String,
456    pub region: String,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize, Default)]
460#[serde(default)]
461pub struct TierTencent {
462    pub name: String,
463    pub endpoint: String,
464    #[serde(rename = "accessKey")]
465    pub access_key: String,
466    #[serde(rename = "secretKey")]
467    pub secret_key: String,
468    pub bucket: String,
469    pub prefix: String,
470    pub region: String,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, Default)]
474#[serde(default)]
475pub struct TierHuaweicloud {
476    pub name: String,
477    pub endpoint: String,
478    #[serde(rename = "accessKey")]
479    pub access_key: String,
480    #[serde(rename = "secretKey")]
481    pub secret_key: String,
482    pub bucket: String,
483    pub prefix: String,
484    pub region: String,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize, Default)]
488#[serde(default)]
489pub struct TierAzure {
490    pub name: String,
491    pub endpoint: String,
492    #[serde(rename = "accessKey")]
493    pub access_key: String,
494    #[serde(rename = "secretKey")]
495    pub secret_key: String,
496    pub bucket: String,
497    pub prefix: String,
498    pub region: String,
499    #[serde(rename = "storageClass")]
500    pub storage_class: String,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize, Default)]
504#[serde(default)]
505pub struct TierGCS {
506    pub name: String,
507    pub endpoint: String,
508    #[serde(rename = "creds")]
509    pub creds: String,
510    pub bucket: String,
511    pub prefix: String,
512    pub region: String,
513    #[serde(rename = "storageClass")]
514    pub storage_class: String,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, Default)]
518#[serde(default)]
519pub struct TierR2 {
520    pub name: String,
521    pub endpoint: String,
522    #[serde(rename = "accessKey")]
523    pub access_key: String,
524    #[serde(rename = "secretKey")]
525    pub secret_key: String,
526    pub bucket: String,
527    pub prefix: String,
528    pub region: String,
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn test_tier_type_display() {
537        assert_eq!(TierType::S3.to_string(), "S3");
538        assert_eq!(TierType::RustFS.to_string(), "RustFS");
539        assert_eq!(TierType::MinIO.to_string(), "MinIO");
540        assert_eq!(TierType::Azure.to_string(), "Azure");
541        assert_eq!(TierType::GCS.to_string(), "GCS");
542        assert_eq!(TierType::R2.to_string(), "R2");
543    }
544
545    #[test]
546    fn test_tier_type_from_str() {
547        assert_eq!("s3".parse::<TierType>().unwrap(), TierType::S3);
548        assert_eq!("rustfs".parse::<TierType>().unwrap(), TierType::RustFS);
549        assert_eq!("MINIO".parse::<TierType>().unwrap(), TierType::MinIO);
550        assert_eq!("Azure".parse::<TierType>().unwrap(), TierType::Azure);
551        assert!("invalid".parse::<TierType>().is_err());
552    }
553
554    #[test]
555    fn test_tier_config_serialization_s3() {
556        let config = TierConfig {
557            tier_type: TierType::S3,
558            name: "WARM".to_string(),
559            s3: Some(TierS3 {
560                name: "WARM".to_string(),
561                endpoint: "https://s3.amazonaws.com".to_string(),
562                access_key: "AKID".to_string(),
563                secret_key: "REDACTED".to_string(),
564                bucket: "warm-bucket".to_string(),
565                prefix: "tier/".to_string(),
566                region: "us-east-1".to_string(),
567                storage_class: "STANDARD_IA".to_string(),
568            }),
569            ..Default::default()
570        };
571
572        let json = serde_json::to_string(&config).unwrap();
573        assert!(json.contains(r#""type":"s3""#));
574        assert!(json.contains("warm-bucket"));
575
576        let decoded: TierConfig = serde_json::from_str(&json).unwrap();
577        assert_eq!(decoded.tier_type, TierType::S3);
578        assert_eq!(decoded.tier_name(), "WARM");
579        assert_eq!(decoded.bucket(), "warm-bucket");
580    }
581
582    #[test]
583    fn test_tier_config_deserialization_from_backend() {
584        // Simulates the JSON format returned by the RustFS admin API
585        let json = r#"{"type":"rustfs","rustfs":{"name":"ARCHIVE","endpoint":"http://remote:9000","accessKey":"admin","secretKey":"REDACTED","bucket":"archive","prefix":"","region":""}}"#;
586        let config: TierConfig = serde_json::from_str(json).unwrap();
587        assert_eq!(config.tier_type, TierType::RustFS);
588        assert_eq!(config.tier_name(), "ARCHIVE");
589        assert_eq!(config.endpoint(), "http://remote:9000");
590        assert_eq!(config.bucket(), "archive");
591    }
592
593    #[test]
594    fn test_tier_creds_serialization() {
595        let creds = TierCreds {
596            access_key: "newkey".to_string(),
597            secret_key: "newsecret".to_string(),
598        };
599
600        let json = serde_json::to_string(&creds).unwrap();
601        assert!(json.contains("accessKey"));
602        assert!(json.contains("secretKey"));
603
604        let decoded: TierCreds = serde_json::from_str(&json).unwrap();
605        assert_eq!(decoded.access_key, "newkey");
606    }
607}