Skip to main content

shipper_types/storage/
mod.rs

1//! Storage backend configuration types.
2//!
3//! These are pure data types describing which storage backend to use. Embedders
4//! use [`CloudStorageConfig`] and [`StorageType`] to declare their storage
5//! choice through the stable `shipper-types` contract crate.
6//!
7//! The runtime trait (`StorageBackend`) and filesystem implementation live in
8//! `shipper::ops::storage` as crate-private internals — only filesystem storage
9//! is fully implemented today, so we do not promise a public `StorageBackend`
10//! trait until cloud backends are real.
11
12use serde::{Deserialize, Serialize};
13
14/// Represents the type of storage backend.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
16pub enum StorageType {
17    /// Local filesystem storage
18    #[default]
19    File,
20    /// Amazon S3 storage
21    S3,
22    /// Google Cloud Storage
23    Gcs,
24    /// Azure Blob Storage
25    Azure,
26}
27
28impl std::fmt::Display for StorageType {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            StorageType::File => write!(f, "file"),
32            StorageType::S3 => write!(f, "s3"),
33            StorageType::Gcs => write!(f, "gcs"),
34            StorageType::Azure => write!(f, "azure"),
35        }
36    }
37}
38
39/// Error returned when parsing an unknown storage type name.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ParseStorageTypeError(pub String);
42
43impl std::fmt::Display for ParseStorageTypeError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        write!(f, "unknown storage type: {}", self.0)
46    }
47}
48
49impl std::error::Error for ParseStorageTypeError {}
50
51impl std::str::FromStr for StorageType {
52    type Err = ParseStorageTypeError;
53
54    fn from_str(s: &str) -> Result<Self, Self::Err> {
55        match s.to_lowercase().as_str() {
56            "file" | "local" => Ok(StorageType::File),
57            "s3" => Ok(StorageType::S3),
58            "gcs" | "gs" => Ok(StorageType::Gcs),
59            "azure" | "blob" => Ok(StorageType::Azure),
60            _ => Err(ParseStorageTypeError(s.to_string())),
61        }
62    }
63}
64
65/// Configuration for any storage backend.
66///
67/// Pure data: no I/O, no policy decisions. Embedders construct this to
68/// describe "use this storage backend" via the stable contract surface.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CloudStorageConfig {
71    /// Storage type (file, s3, gcs, azure)
72    pub storage_type: StorageType,
73    /// Bucket/container name
74    pub bucket: String,
75    /// Region for S3, project ID for GCS
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub region: Option<String>,
78    /// Base path within the bucket
79    #[serde(default)]
80    pub base_path: String,
81    /// Custom endpoint (for S3-compatible services like MinIO, DigitalOcean Spaces)
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub endpoint: Option<String>,
84    /// Access key ID
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub access_key_id: Option<String>,
87    /// Secret access key
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub secret_access_key: Option<String>,
90    /// Session token (for temporary credentials)
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub session_token: Option<String>,
93}
94
95impl Default for CloudStorageConfig {
96    fn default() -> Self {
97        Self {
98            storage_type: StorageType::File,
99            bucket: String::new(),
100            region: None,
101            base_path: String::new(),
102            endpoint: None,
103            access_key_id: None,
104            secret_access_key: None,
105            session_token: None,
106        }
107    }
108}
109
110/// Error returned when validating a [`CloudStorageConfig`].
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ValidateStorageConfigError(pub String);
113
114impl std::fmt::Display for ValidateStorageConfigError {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.write_str(&self.0)
117    }
118}
119
120impl std::error::Error for ValidateStorageConfigError {}
121
122impl CloudStorageConfig {
123    /// Create a new CloudStorageConfig with the given bucket
124    pub fn new(storage_type: StorageType, bucket: impl Into<String>) -> Self {
125        Self {
126            storage_type,
127            bucket: bucket.into(),
128            ..Default::default()
129        }
130    }
131
132    /// Create a file storage config
133    pub fn file(base_path: impl Into<String>) -> Self {
134        Self {
135            storage_type: StorageType::File,
136            base_path: base_path.into(),
137            ..Default::default()
138        }
139    }
140
141    /// Create an S3 storage config
142    pub fn s3(bucket: impl Into<String>) -> Self {
143        Self::new(StorageType::S3, bucket)
144    }
145
146    /// Create a GCS storage config
147    pub fn gcs(bucket: impl Into<String>) -> Self {
148        Self::new(StorageType::Gcs, bucket)
149    }
150
151    /// Create an Azure storage config
152    pub fn azure(container: impl Into<String>) -> Self {
153        Self::new(StorageType::Azure, container)
154    }
155
156    /// Set the region
157    pub fn with_region(mut self, region: impl Into<String>) -> Self {
158        self.region = Some(region.into());
159        self
160    }
161
162    /// Set the base path
163    pub fn with_base_path(mut self, path: impl Into<String>) -> Self {
164        self.base_path = path.into();
165        self
166    }
167
168    /// Set custom endpoint (for S3-compatible services)
169    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
170        self.endpoint = Some(endpoint.into());
171        self
172    }
173
174    /// Set credentials
175    pub fn with_credentials(
176        mut self,
177        access_key_id: impl Into<String>,
178        secret_access_key: impl Into<String>,
179    ) -> Self {
180        self.access_key_id = Some(access_key_id.into());
181        self.secret_access_key = Some(secret_access_key.into());
182        self
183    }
184
185    /// Set session token
186    pub fn with_session_token(mut self, token: impl Into<String>) -> Self {
187        self.session_token = Some(token.into());
188        self
189    }
190
191    /// Build full path from relative path
192    pub fn full_path(&self, relative_path: &str) -> String {
193        if self.base_path.is_empty() {
194            relative_path.to_string()
195        } else {
196            format!("{}/{}", self.base_path.trim_end_matches('/'), relative_path)
197        }
198    }
199
200    /// Validate the configuration
201    pub fn validate(&self) -> Result<(), ValidateStorageConfigError> {
202        match self.storage_type {
203            StorageType::File => {
204                // File storage is always valid
205                Ok(())
206            }
207            StorageType::S3 | StorageType::Gcs | StorageType::Azure => {
208                if self.bucket.is_empty() {
209                    Err(ValidateStorageConfigError(
210                        "bucket/container name is required for cloud storage".to_string(),
211                    ))
212                } else {
213                    Ok(())
214                }
215            }
216        }
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use std::str::FromStr;
224
225    #[test]
226    fn storage_type_from_str() {
227        assert_eq!(StorageType::from_str("file").unwrap(), StorageType::File);
228        assert_eq!(StorageType::from_str("local").unwrap(), StorageType::File);
229        assert_eq!(StorageType::from_str("s3").unwrap(), StorageType::S3);
230        assert_eq!(StorageType::from_str("gcs").unwrap(), StorageType::Gcs);
231        assert_eq!(StorageType::from_str("gs").unwrap(), StorageType::Gcs);
232        assert_eq!(StorageType::from_str("azure").unwrap(), StorageType::Azure);
233        assert!(StorageType::from_str("unknown").is_err());
234    }
235
236    #[test]
237    fn storage_type_display() {
238        assert_eq!(StorageType::File.to_string(), "file");
239        assert_eq!(StorageType::S3.to_string(), "s3");
240        assert_eq!(StorageType::Gcs.to_string(), "gcs");
241        assert_eq!(StorageType::Azure.to_string(), "azure");
242    }
243
244    #[test]
245    fn storage_type_default() {
246        assert_eq!(StorageType::default(), StorageType::File);
247    }
248
249    #[test]
250    fn cloud_storage_config_new() {
251        let config = CloudStorageConfig::new(StorageType::S3, "my-bucket");
252        assert_eq!(config.storage_type, StorageType::S3);
253        assert_eq!(config.bucket, "my-bucket");
254        assert!(config.region.is_none());
255    }
256
257    #[test]
258    fn cloud_storage_config_file() {
259        let config = CloudStorageConfig::file("/path/to/state");
260        assert_eq!(config.storage_type, StorageType::File);
261        assert_eq!(config.base_path, "/path/to/state");
262    }
263
264    #[test]
265    fn cloud_storage_config_s3() {
266        let config = CloudStorageConfig::s3("my-bucket")
267            .with_region("us-west-2")
268            .with_credentials("key", "secret");
269
270        assert_eq!(config.storage_type, StorageType::S3);
271        assert_eq!(config.bucket, "my-bucket");
272        assert_eq!(config.region, Some("us-west-2".to_string()));
273    }
274
275    #[test]
276    fn cloud_storage_config_full_path() {
277        let config = CloudStorageConfig::s3("bucket").with_base_path("prefix");
278        assert_eq!(config.full_path("state.json"), "prefix/state.json");
279
280        let config2 = CloudStorageConfig::s3("bucket");
281        assert_eq!(config2.full_path("state.json"), "state.json");
282    }
283
284    #[test]
285    fn cloud_storage_config_full_path_trailing_slash() {
286        let config = CloudStorageConfig::s3("b").with_base_path("prefix/");
287        assert_eq!(config.full_path("key.json"), "prefix/key.json");
288    }
289
290    #[test]
291    fn cloud_storage_config_validate() {
292        let config = CloudStorageConfig::file("/path");
293        assert!(config.validate().is_ok());
294
295        let config2 = CloudStorageConfig::s3(""); // Empty bucket
296        assert!(config2.validate().is_err());
297    }
298
299    #[test]
300    fn cloud_storage_config_serialization() {
301        let config = CloudStorageConfig::s3("bucket")
302            .with_region("us-east-1")
303            .with_base_path("prefix");
304
305        let json = serde_json::to_string(&config).expect("serialize");
306        assert!(json.contains("\"storage_type\":\"S3\""));
307        assert!(json.contains("\"bucket\":\"bucket\""));
308        assert!(json.contains("\"region\":\"us-east-1\""));
309    }
310
311    #[test]
312    fn storage_type_parse_round_trip() {
313        for (input, expected) in [
314            ("file", StorageType::File),
315            ("local", StorageType::File),
316            ("s3", StorageType::S3),
317            ("gcs", StorageType::Gcs),
318            ("gs", StorageType::Gcs),
319            ("azure", StorageType::Azure),
320            ("blob", StorageType::Azure),
321        ] {
322            let parsed: StorageType = input.parse().unwrap();
323            assert_eq!(parsed, expected);
324        }
325    }
326
327    #[test]
328    fn storage_type_unknown_input_fails() {
329        let result: Result<StorageType, _> = "ftp".parse();
330        assert!(result.is_err());
331    }
332
333    mod proptests {
334        use super::*;
335        use proptest::prelude::*;
336
337        fn safe_name_strategy() -> impl Strategy<Value = String> {
338            "[a-zA-Z0-9][a-zA-Z0-9_]{0,19}".prop_filter("non-empty", |s| !s.is_empty())
339        }
340
341        proptest! {
342            #[test]
343            fn cloud_config_full_path_with_base(
344                base in safe_name_strategy(),
345                relative in safe_name_strategy(),
346            ) {
347                let config = CloudStorageConfig::s3("bucket").with_base_path(&base);
348                let full = config.full_path(&relative);
349                prop_assert!(full.starts_with(&base));
350                prop_assert!(full.ends_with(&relative));
351                prop_assert!(full.contains('/'));
352            }
353
354            #[test]
355            fn cloud_config_full_path_no_base(relative in safe_name_strategy()) {
356                let config = CloudStorageConfig::s3("bucket");
357                let full = config.full_path(&relative);
358                prop_assert_eq!(full, relative);
359            }
360        }
361    }
362}
363
364#[cfg(test)]
365mod snapshot_tests {
366    use super::*;
367    use insta::assert_yaml_snapshot;
368    use std::str::FromStr;
369
370    #[test]
371    fn storage_type_display_all() {
372        let displays: Vec<String> = [
373            StorageType::File,
374            StorageType::S3,
375            StorageType::Gcs,
376            StorageType::Azure,
377        ]
378        .iter()
379        .map(|t| t.to_string())
380        .collect();
381        assert_yaml_snapshot!(displays);
382    }
383
384    #[test]
385    fn storage_type_serde_roundtrip() {
386        let types = vec![
387            StorageType::File,
388            StorageType::S3,
389            StorageType::Gcs,
390            StorageType::Azure,
391        ];
392        assert_yaml_snapshot!(types);
393    }
394
395    #[test]
396    fn storage_type_default_snap() {
397        assert_yaml_snapshot!(StorageType::default());
398    }
399
400    #[test]
401    fn storage_type_from_str_aliases() {
402        let cases: Vec<(&str, String)> = vec!["file", "local", "s3", "gcs", "gs", "azure", "blob"]
403            .into_iter()
404            .map(|s| (s, StorageType::from_str(s).unwrap().to_string()))
405            .collect();
406        assert_yaml_snapshot!(cases);
407    }
408
409    #[test]
410    fn storage_type_from_str_error() {
411        let err = StorageType::from_str("ftp").unwrap_err();
412        assert_yaml_snapshot!(err.to_string());
413    }
414
415    #[test]
416    fn cloud_config_s3_full() {
417        let config = CloudStorageConfig::s3("my-releases")
418            .with_region("eu-west-1")
419            .with_base_path("shipper/state")
420            .with_endpoint("https://s3.custom.example.com")
421            .with_credentials("AKIAEXAMPLE", "secret-key")
422            .with_session_token("session-tok");
423        assert_yaml_snapshot!(config);
424    }
425
426    #[test]
427    fn cloud_config_minimal_file() {
428        let config = CloudStorageConfig::file(".shipper");
429        assert_yaml_snapshot!(config);
430    }
431
432    #[test]
433    fn cloud_config_gcs() {
434        let config = CloudStorageConfig::gcs("gcs-bucket").with_region("us-central1");
435        assert_yaml_snapshot!(config);
436    }
437
438    #[test]
439    fn cloud_config_azure() {
440        let config = CloudStorageConfig::azure("my-container").with_base_path("releases/v1");
441        assert_yaml_snapshot!(config);
442    }
443
444    #[test]
445    fn cloud_config_default() {
446        assert_yaml_snapshot!(CloudStorageConfig::default());
447    }
448
449    #[test]
450    fn cloud_config_full_path_variants() {
451        let results: Vec<(&str, &str, String)> = vec![
452            (
453                "prefix",
454                "state.json",
455                CloudStorageConfig::s3("b")
456                    .with_base_path("prefix")
457                    .full_path("state.json"),
458            ),
459            (
460                "prefix/",
461                "state.json",
462                CloudStorageConfig::s3("b")
463                    .with_base_path("prefix/")
464                    .full_path("state.json"),
465            ),
466            (
467                "",
468                "state.json",
469                CloudStorageConfig::s3("b").full_path("state.json"),
470            ),
471            (
472                "a/b/c",
473                "d.json",
474                CloudStorageConfig::s3("b")
475                    .with_base_path("a/b/c")
476                    .full_path("d.json"),
477            ),
478        ];
479        assert_yaml_snapshot!(results);
480    }
481
482    #[test]
483    fn cloud_config_validate_errors() {
484        let cases: Vec<(&str, String)> = vec![
485            (
486                "s3_empty_bucket",
487                CloudStorageConfig::s3("")
488                    .validate()
489                    .unwrap_err()
490                    .to_string(),
491            ),
492            (
493                "gcs_empty_bucket",
494                CloudStorageConfig::gcs("")
495                    .validate()
496                    .unwrap_err()
497                    .to_string(),
498            ),
499            (
500                "azure_empty_bucket",
501                CloudStorageConfig::azure("")
502                    .validate()
503                    .unwrap_err()
504                    .to_string(),
505            ),
506        ];
507        assert_yaml_snapshot!(cases);
508    }
509
510    #[test]
511    fn cloud_config_validate_file_always_ok() {
512        let result = CloudStorageConfig::file("").validate().is_ok();
513        assert_yaml_snapshot!(result);
514    }
515
516    #[test]
517    fn cloud_config_json_roundtrip() {
518        let config = CloudStorageConfig::s3("my-bucket")
519            .with_region("ap-southeast-1")
520            .with_base_path("releases");
521
522        let json = serde_json::to_string_pretty(&config).unwrap();
523        let parsed: CloudStorageConfig = serde_json::from_str(&json).unwrap();
524        assert_yaml_snapshot!("json_output", json);
525        assert_yaml_snapshot!("parsed_back", parsed);
526    }
527
528    #[test]
529    fn snapshot_debug_storage_type_all() {
530        let types = vec![
531            StorageType::File,
532            StorageType::S3,
533            StorageType::Gcs,
534            StorageType::Azure,
535        ];
536        insta::assert_debug_snapshot!(types);
537    }
538
539    #[test]
540    fn snapshot_debug_cloud_config_all_options() {
541        let config = CloudStorageConfig::s3("release-artifacts")
542            .with_region("eu-central-1")
543            .with_base_path("shipper/state")
544            .with_endpoint("https://minio.internal:9000")
545            .with_credentials("ACCESS_KEY", "SECRET_KEY")
546            .with_session_token("session-token-xyz");
547        insta::assert_debug_snapshot!(config);
548    }
549
550    #[test]
551    fn snapshot_debug_cloud_config_defaults() {
552        insta::assert_debug_snapshot!(CloudStorageConfig::default());
553    }
554}