1use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
16pub enum StorageType {
17 #[default]
19 File,
20 S3,
22 Gcs,
24 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CloudStorageConfig {
71 pub storage_type: StorageType,
73 pub bucket: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub region: Option<String>,
78 #[serde(default)]
80 pub base_path: String,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub endpoint: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub access_key_id: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub secret_access_key: Option<String>,
90 #[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#[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 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 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 pub fn s3(bucket: impl Into<String>) -> Self {
143 Self::new(StorageType::S3, bucket)
144 }
145
146 pub fn gcs(bucket: impl Into<String>) -> Self {
148 Self::new(StorageType::Gcs, bucket)
149 }
150
151 pub fn azure(container: impl Into<String>) -> Self {
153 Self::new(StorageType::Azure, container)
154 }
155
156 pub fn with_region(mut self, region: impl Into<String>) -> Self {
158 self.region = Some(region.into());
159 self
160 }
161
162 pub fn with_base_path(mut self, path: impl Into<String>) -> Self {
164 self.base_path = path.into();
165 self
166 }
167
168 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
170 self.endpoint = Some(endpoint.into());
171 self
172 }
173
174 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 pub fn with_session_token(mut self, token: impl Into<String>) -> Self {
187 self.session_token = Some(token.into());
188 self
189 }
190
191 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 pub fn validate(&self) -> Result<(), ValidateStorageConfigError> {
202 match self.storage_type {
203 StorageType::File => {
204 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(""); 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}