Skip to main content

alien_core/resources/
storage.rs

1use crate::{
2    error::{ErrorData, Result},
3    resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef},
4    ResourceType,
5};
6use alien_error::AlienError;
7use bon::Builder;
8use serde::{Deserialize, Serialize};
9use std::{any::Any, fmt::Debug};
10
11/// Days after which S3 aborts a multipart upload that was started and never completed. Every
12/// S3 bucket Alien creates, whether setup (CloudFormation, Terraform) or the storage controller
13/// creates it, carries this rule next to the app's own lifecycle rules. It gives each bucket the
14/// lifecycle configuration that compliance checks require and never expires a stored object.
15pub const AWS_ABORT_INCOMPLETE_UPLOAD_DAYS: i32 = 7;
16
17/// Most lifecycle rules S3 accepts on one bucket. The incomplete-upload rule is added only while
18/// a bucket has room for it: a bucket at the limit already has a lifecycle configuration.
19pub const AWS_LIFECYCLE_RULE_LIMIT: usize = 1000;
20
21/// Id of the incomplete-upload rule. App rules are numbered (`Rule1`, `rule-1`), so it can't
22/// collide with one.
23pub const AWS_ABORT_INCOMPLETE_UPLOAD_RULE_ID: &str = "abort-incomplete-uploads";
24
25/// Request headers an S3 CORS rule lets a browser send with a signed upload (`PUT` to a
26/// presigned URL): the content headers a signer may bind, and `x-amz-*`, which covers the
27/// checksums AWS SDKs add and `x-amz-meta-*`. S3 allows one `*` per header.
28pub const AWS_SIGNED_UPLOAD_HEADERS: &[&str] = &[
29    "Content-Type",
30    "Content-MD5",
31    "Content-Disposition",
32    "Content-Encoding",
33    "Cache-Control",
34    "x-amz-*",
35];
36
37/// Headers a GCS CORS rule lists for a signed upload. GCS uses one list (`responseHeader`)
38/// for both the request headers a browser may send and the response headers it may read,
39/// and matches each name exactly, so `x-goog-meta-*` headers can't be listed.
40pub const GCP_SIGNED_UPLOAD_HEADERS: &[&str] = &[
41    "Content-Type",
42    "Content-MD5",
43    "Content-Disposition",
44    "Content-Encoding",
45    "Cache-Control",
46    "x-goog-content-length-range",
47    "ETag",
48];
49
50/// Request headers an Azure Blob CORS rule lets a browser send with a signed upload (`PUT`
51/// to a SAS URL). `x-ms-*` covers `x-ms-blob-type`, which Put Blob requires, the
52/// `x-ms-blob-content-*` and `x-ms-meta-*` headers, and the `x-ms-version` and
53/// `x-ms-client-request-id` headers the Azure SDK sends.
54pub const AZURE_SIGNED_UPLOAD_HEADERS: &[&str] = &["Content-Type", "Content-MD5", "x-ms-*"];
55
56/// Response headers a browser may read after a signed upload.
57pub const SIGNED_UPLOAD_EXPOSED_HEADERS: &[&str] = &["ETag"];
58
59/// Defines a rule for managing the lifecycle of objects within a storage bucket.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
62#[serde(rename_all = "camelCase", deny_unknown_fields)]
63pub struct LifecycleRule {
64    /// Number of days after which objects matching the rule expire.
65    pub days: u32,
66    /// Optional prefix to filter objects the rule applies to. If None, applies to all objects.
67    #[serde(default)]
68    pub prefix: Option<String>,
69}
70
71/// Object events that can be delivered to a queue.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
74#[serde(rename_all = "camelCase")]
75pub enum StorageNotificationEvent {
76    /// An object was created or overwritten, including a completed multipart upload.
77    Created,
78}
79
80fn default_notification_events() -> Vec<StorageNotificationEvent> {
81    vec![StorageNotificationEvent::Created]
82}
83
84/// Delivers matching object events to a queue in the same stack.
85/// Rules on one Storage must not overlap: no object key may match two of them.
86/// Delivery is at least once, so consumers must tolerate duplicates.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
88#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89#[serde(rename_all = "camelCase", deny_unknown_fields)]
90pub struct StorageNotification {
91    /// Target Queue. It must have the same lifecycle as the Storage.
92    pub queue: ResourceRef,
93    /// Events to deliver. Defaults to object creation; v1 supports only `created`.
94    #[serde(default = "default_notification_events")]
95    #[builder(default = default_notification_events())]
96    pub events: Vec<StorageNotificationEvent>,
97    /// Literal object key prefix, non-empty and at most 512 UTF-8 bytes when set.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub prefix: Option<String>,
100    /// Literal object key suffix, non-empty and at most 128 UTF-8 bytes when set.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub suffix: Option<String>,
103}
104
105impl StorageNotification {
106    /// Match literal prefixes and suffixes; wildcard characters have no special meaning.
107    pub fn matches_key(&self, key: &str) -> bool {
108        self.prefix
109            .as_ref()
110            .is_none_or(|prefix| key.starts_with(prefix))
111            && self
112                .suffix
113                .as_ref()
114                .is_none_or(|suffix| key.ends_with(suffix))
115    }
116}
117
118/// Represents an object storage bucket.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
120#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
121#[serde(rename_all = "camelCase", deny_unknown_fields)]
122#[builder(start_fn = new)]
123pub struct Storage {
124    /// Name of the the storage bucket.
125    /// For names with dots, each dot-separated label must be ≤ 63 characters.
126    #[builder(start_fn)]
127    pub id: String,
128
129    /// Allows public read access to objects without authentication.
130    /// Default: `false`
131    #[serde(default)]
132    #[builder(default)]
133    pub public_read: bool,
134
135    /// Enables object versioning.
136    /// Default: `false`
137    #[serde(default)]
138    #[builder(default)]
139    pub versioning: bool,
140
141    /// List of rules for automatic object management (e.g., expiration).
142    /// Default: `[]` (empty list)
143    #[serde(default)]
144    #[builder(default)]
145    pub lifecycle_rules: Vec<LifecycleRule>,
146
147    /// Browser origins allowed to read objects through signed URLs.
148    ///
149    /// When non-empty, providers configure CORS for `GET` and `HEAD` requests.
150    /// An origin of `*` is appropriate for private buckets whose signed URLs
151    /// are bearer credentials and do not use browser cookies.
152    /// Default: `[]` (CORS disabled).
153    #[serde(default)]
154    #[builder(default)]
155    pub cors_allowed_origins: Vec<String>,
156
157    /// Browser origins allowed to upload objects through signed URLs.
158    ///
159    /// When non-empty, providers also configure CORS for single-request `PUT` uploads from
160    /// these origins (a presigned or SAS URL, or one presigned multipart part) and expose
161    /// `ETag`. Allowed request headers: `Content-Type` and `Content-MD5` everywhere;
162    /// `Content-Disposition`, `Content-Encoding`, `Cache-Control` and `x-amz-*` on S3;
163    /// the same content headers and `x-goog-content-length-range` on Cloud Storage, which
164    /// can't allow `x-goog-meta-*`; `x-ms-*` on Azure. Uploads that start with `POST`, such
165    /// as multipart create and complete or resumable uploads, are not covered.
166    ///
167    /// Each origin is `*` or `scheme://host[:port]`; S3 also accepts one `*` wildcard, such
168    /// as `https://*.example.com`.
169    /// Omitted when empty to preserve existing serialized configurations and setup digests.
170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171    #[builder(default)]
172    pub cors_upload_origins: Vec<String>,
173
174    /// Object notification rules delivered into queues (at most 10).
175    /// Omitted when empty to preserve existing serialized configurations and setup digests.
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    #[builder(default)]
178    #[cfg_attr(feature = "openapi", schema(max_items = 10))]
179    pub notifications: Vec<StorageNotification>,
180
181    /// Customer-managed Key used by the provider's native storage encryption.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub encryption_key: Option<ResourceRef>,
184}
185
186impl Storage {
187    /// The resource type identifier for Storage
188    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("storage");
189
190    /// Returns the storage's unique identifier.
191    pub fn id(&self) -> &str {
192        &self.id
193    }
194
195    /// Whether the storage asks for any browser CORS rule, for reads or for signed uploads.
196    pub fn has_cors(&self) -> bool {
197        !self.cors_allowed_origins.is_empty() || !self.cors_upload_origins.is_empty()
198    }
199}
200
201/// Outputs generated by a successfully provisioned Storage bucket.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
204#[serde(rename_all = "camelCase")]
205pub struct StorageOutputs {
206    /// The globally unique name of the bucket.
207    pub bucket_name: String,
208    // Add other outputs like region or URL if needed later
209}
210
211impl ResourceOutputsDefinition for StorageOutputs {
212    fn get_resource_type(&self) -> ResourceType {
213        Storage::RESOURCE_TYPE.clone()
214    }
215
216    fn as_any(&self) -> &dyn Any {
217        self
218    }
219
220    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
221        Box::new(self.clone())
222    }
223
224    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
225        other.as_any().downcast_ref::<StorageOutputs>() == Some(self)
226    }
227
228    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
229        serde_json::to_value(self)
230    }
231}
232
233// Implementation of ResourceDefinition trait for Storage
234impl ResourceDefinition for Storage {
235    fn get_resource_type(&self) -> ResourceType {
236        Self::RESOURCE_TYPE
237    }
238
239    fn id(&self) -> &str {
240        &self.id
241    }
242
243    fn replace_after_failed_create_is_safe(&self) -> bool {
244        // Deleting it destroys the objects in the bucket.
245        false
246    }
247
248    fn get_dependencies(&self) -> Vec<ResourceRef> {
249        let mut dependencies: Vec<_> = self.encryption_key.iter().cloned().collect();
250        for notification in &self.notifications {
251            if !dependencies.contains(&notification.queue) {
252                dependencies.push(notification.queue.clone());
253            }
254        }
255        dependencies
256    }
257
258    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
259        // Downcast to Storage type to use the existing validate_update method
260        let new_storage = new_config
261            .as_any()
262            .downcast_ref::<Storage>()
263            .ok_or_else(|| {
264                AlienError::new(ErrorData::UnexpectedResourceType {
265                    resource_id: self.id.clone(),
266                    expected: Self::RESOURCE_TYPE,
267                    actual: new_config.get_resource_type(),
268                })
269            })?;
270
271        if self.id != new_storage.id {
272            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
273                resource_id: self.id.clone(),
274                reason: "the 'id' field is immutable".to_string(),
275            }));
276        }
277        if self.encryption_key != new_storage.encryption_key {
278            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
279                resource_id: self.id.clone(),
280                reason: "the 'encryptionKey' field is immutable once the resource exists"
281                    .to_string(),
282            }));
283        }
284        Ok(())
285    }
286
287    fn as_any(&self) -> &dyn Any {
288        self
289    }
290
291    fn as_any_mut(&mut self) -> &mut dyn Any {
292        self
293    }
294
295    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
296        Box::new(self.clone())
297    }
298
299    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
300        other.as_any().downcast_ref::<Storage>() == Some(self)
301    }
302
303    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
304        serde_json::to_value(self)
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn encryption_key_is_an_implicit_dependency() {
314        let key = ResourceRef::new(crate::Key::RESOURCE_TYPE, "customer-key");
315        let storage = Storage::new("customer-data".to_string())
316            .encryption_key(key.clone())
317            .build();
318
319        assert_eq!(storage.get_dependencies(), vec![key]);
320    }
321
322    #[test]
323    fn notifications_preserve_existing_serialization_and_deduplicate_dependencies() {
324        let original: Storage = serde_json::from_value(serde_json::json!({
325            "id": "objects", "publicRead": false, "versioning": false,
326            "lifecycleRules": [], "corsAllowedOrigins": []
327        }))
328        .unwrap();
329        assert!(original.notifications.is_empty());
330        assert_eq!(
331            serde_json::to_value(&original).unwrap(),
332            serde_json::json!({
333                "id": "objects", "publicRead": false, "versioning": false,
334                "lifecycleRules": [], "corsAllowedOrigins": []
335            })
336        );
337        let key = ResourceRef::new(crate::Key::RESOURCE_TYPE, "key");
338        let queue = ResourceRef::new(crate::Queue::RESOURCE_TYPE, "work");
339        let rule = StorageNotification::builder().queue(queue.clone()).build();
340        let storage = Storage::new("objects".to_string())
341            .encryption_key(key.clone())
342            .notifications(vec![rule.clone(), rule])
343            .build();
344        assert_eq!(storage.get_dependencies(), vec![key, queue]);
345        let encoded = serde_json::to_value(&storage).unwrap();
346        assert_eq!(
347            encoded["notifications"][0]["events"],
348            serde_json::json!(["created"])
349        );
350        assert_eq!(serde_json::from_value::<Storage>(encoded).unwrap(), storage);
351    }
352
353    #[test]
354    fn upload_origins_round_trip_and_stay_out_of_existing_serialization() {
355        let declared = serde_json::json!({
356            "id": "uploads", "publicRead": false, "versioning": false,
357            "lifecycleRules": [], "corsAllowedOrigins": [],
358            "corsUploadOrigins": ["https://app.example.com"]
359        });
360        let storage: Storage = serde_json::from_value(declared.clone())
361            .expect("a storage declaration may allow signed browser uploads");
362        assert_eq!(storage.cors_upload_origins, ["https://app.example.com"]);
363        assert!(storage.has_cors());
364        assert_eq!(serde_json::to_value(&storage).unwrap(), declared);
365
366        let read_only = Storage::new("objects".to_string())
367            .cors_allowed_origins(vec!["https://app.example.com".into()])
368            .build();
369        assert!(read_only.has_cors());
370        assert!(serde_json::to_value(&read_only)
371            .unwrap()
372            .get("corsUploadOrigins")
373            .is_none());
374        assert!(!Storage::new("plain".to_string()).build().has_cors());
375    }
376
377    #[test]
378    fn notification_filters_are_literal_case_sensitive_and_combined() {
379        let rule = StorageNotification::builder()
380            .queue(ResourceRef::new(crate::Queue::RESOURCE_TYPE, "work"))
381            .prefix("in/*/é/".to_string())
382            .suffix(".json".to_string())
383            .build();
384        assert!(rule.matches_key("in/*/é/object.json"));
385        for key in [
386            "in/a/é/object.json",
387            "in/*/e/object.json",
388            "in/*/é/object.JSON",
389            "other/object.json",
390            "in/*/é/object.json.tmp",
391        ] {
392            assert!(!rule.matches_key(key), "{key}");
393        }
394        let mut unfiltered = rule.clone();
395        unfiltered.prefix = None;
396        unfiltered.suffix = None;
397        assert!(unfiltered.matches_key("anything"));
398        unfiltered.suffix = Some(".json".to_string());
399        assert!(unfiltered.matches_key("any/path.json"));
400        assert!(!unfiltered.matches_key("any/path.txt"));
401        unfiltered.prefix = Some("same".to_string());
402        unfiltered.suffix = Some("same".to_string());
403        assert!(unfiltered.matches_key("same"));
404    }
405
406    #[test]
407    fn notification_events_default_to_created_and_reject_unknown_events() {
408        let rule: StorageNotification = serde_json::from_value(serde_json::json!({
409            "queue": { "type": "queue", "id": "work" }
410        }))
411        .unwrap();
412        assert_eq!(rule.events, vec![StorageNotificationEvent::Created]);
413        assert!(
414            serde_json::from_value::<StorageNotification>(serde_json::json!({
415                "queue": { "type": "queue", "id": "work" }, "events": ["deleted"]
416            }))
417            .is_err()
418        );
419    }
420}