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/// Defines a rule for managing the lifecycle of objects within a storage bucket.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub struct LifecycleRule {
30    /// Number of days after which objects matching the rule expire.
31    pub days: u32,
32    /// Optional prefix to filter objects the rule applies to. If None, applies to all objects.
33    #[serde(default)]
34    pub prefix: Option<String>,
35}
36
37/// Object events that can be delivered to a queue.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
40#[serde(rename_all = "camelCase")]
41pub enum StorageNotificationEvent {
42    /// An object was created or overwritten, including a completed multipart upload.
43    Created,
44}
45
46fn default_notification_events() -> Vec<StorageNotificationEvent> {
47    vec![StorageNotificationEvent::Created]
48}
49
50/// Delivers matching object events to a queue in the same stack.
51/// Rules on one Storage must not overlap: no object key may match two of them.
52/// Delivery is at least once, so consumers must tolerate duplicates.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
54#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
55#[serde(rename_all = "camelCase", deny_unknown_fields)]
56pub struct StorageNotification {
57    /// Target Queue. It must have the same lifecycle as the Storage.
58    pub queue: ResourceRef,
59    /// Events to deliver. Defaults to object creation; v1 supports only `created`.
60    #[serde(default = "default_notification_events")]
61    #[builder(default = default_notification_events())]
62    pub events: Vec<StorageNotificationEvent>,
63    /// Literal object key prefix, non-empty and at most 512 UTF-8 bytes when set.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub prefix: Option<String>,
66    /// Literal object key suffix, non-empty and at most 128 UTF-8 bytes when set.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub suffix: Option<String>,
69}
70
71impl StorageNotification {
72    /// Match literal prefixes and suffixes; wildcard characters have no special meaning.
73    pub fn matches_key(&self, key: &str) -> bool {
74        self.prefix
75            .as_ref()
76            .is_none_or(|prefix| key.starts_with(prefix))
77            && self
78                .suffix
79                .as_ref()
80                .is_none_or(|suffix| key.ends_with(suffix))
81    }
82}
83
84/// Represents an object storage bucket.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
86#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
87#[serde(rename_all = "camelCase", deny_unknown_fields)]
88#[builder(start_fn = new)]
89pub struct Storage {
90    /// Name of the the storage bucket.
91    /// For names with dots, each dot-separated label must be ≤ 63 characters.
92    #[builder(start_fn)]
93    pub id: String,
94
95    /// Allows public read access to objects without authentication.
96    /// Default: `false`
97    #[serde(default)]
98    #[builder(default)]
99    pub public_read: bool,
100
101    /// Enables object versioning.
102    /// Default: `false`
103    #[serde(default)]
104    #[builder(default)]
105    pub versioning: bool,
106
107    /// List of rules for automatic object management (e.g., expiration).
108    /// Default: `[]` (empty list)
109    #[serde(default)]
110    #[builder(default)]
111    pub lifecycle_rules: Vec<LifecycleRule>,
112
113    /// Browser origins allowed to read objects through signed URLs.
114    ///
115    /// When non-empty, providers configure CORS for `GET` and `HEAD` requests.
116    /// An origin of `*` is appropriate for private buckets whose signed URLs
117    /// are bearer credentials and do not use browser cookies.
118    /// Default: `[]` (CORS disabled).
119    #[serde(default)]
120    #[builder(default)]
121    pub cors_allowed_origins: Vec<String>,
122
123    /// Object notification rules delivered into queues (at most 10).
124    /// Omitted when empty to preserve existing serialized configurations and setup digests.
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    #[builder(default)]
127    #[cfg_attr(feature = "openapi", schema(max_items = 10))]
128    pub notifications: Vec<StorageNotification>,
129
130    /// Customer-managed Key used by the provider's native storage encryption.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub encryption_key: Option<ResourceRef>,
133}
134
135impl Storage {
136    /// The resource type identifier for Storage
137    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("storage");
138
139    /// Returns the storage's unique identifier.
140    pub fn id(&self) -> &str {
141        &self.id
142    }
143}
144
145/// Outputs generated by a successfully provisioned Storage bucket.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
148#[serde(rename_all = "camelCase")]
149pub struct StorageOutputs {
150    /// The globally unique name of the bucket.
151    pub bucket_name: String,
152    // Add other outputs like region or URL if needed later
153}
154
155impl ResourceOutputsDefinition for StorageOutputs {
156    fn get_resource_type(&self) -> ResourceType {
157        Storage::RESOURCE_TYPE.clone()
158    }
159
160    fn as_any(&self) -> &dyn Any {
161        self
162    }
163
164    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
165        Box::new(self.clone())
166    }
167
168    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
169        other.as_any().downcast_ref::<StorageOutputs>() == Some(self)
170    }
171
172    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
173        serde_json::to_value(self)
174    }
175}
176
177// Implementation of ResourceDefinition trait for Storage
178impl ResourceDefinition for Storage {
179    fn get_resource_type(&self) -> ResourceType {
180        Self::RESOURCE_TYPE
181    }
182
183    fn id(&self) -> &str {
184        &self.id
185    }
186
187    fn replace_after_failed_create_is_safe(&self) -> bool {
188        // Deleting it destroys the objects in the bucket.
189        false
190    }
191
192    fn get_dependencies(&self) -> Vec<ResourceRef> {
193        let mut dependencies: Vec<_> = self.encryption_key.iter().cloned().collect();
194        for notification in &self.notifications {
195            if !dependencies.contains(&notification.queue) {
196                dependencies.push(notification.queue.clone());
197            }
198        }
199        dependencies
200    }
201
202    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
203        // Downcast to Storage type to use the existing validate_update method
204        let new_storage = new_config
205            .as_any()
206            .downcast_ref::<Storage>()
207            .ok_or_else(|| {
208                AlienError::new(ErrorData::UnexpectedResourceType {
209                    resource_id: self.id.clone(),
210                    expected: Self::RESOURCE_TYPE,
211                    actual: new_config.get_resource_type(),
212                })
213            })?;
214
215        if self.id != new_storage.id {
216            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
217                resource_id: self.id.clone(),
218                reason: "the 'id' field is immutable".to_string(),
219            }));
220        }
221        if self.encryption_key != new_storage.encryption_key {
222            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
223                resource_id: self.id.clone(),
224                reason: "the 'encryptionKey' field is immutable once the resource exists"
225                    .to_string(),
226            }));
227        }
228        Ok(())
229    }
230
231    fn as_any(&self) -> &dyn Any {
232        self
233    }
234
235    fn as_any_mut(&mut self) -> &mut dyn Any {
236        self
237    }
238
239    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
240        Box::new(self.clone())
241    }
242
243    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
244        other.as_any().downcast_ref::<Storage>() == Some(self)
245    }
246
247    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
248        serde_json::to_value(self)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn encryption_key_is_an_implicit_dependency() {
258        let key = ResourceRef::new(crate::Key::RESOURCE_TYPE, "customer-key");
259        let storage = Storage::new("customer-data".to_string())
260            .encryption_key(key.clone())
261            .build();
262
263        assert_eq!(storage.get_dependencies(), vec![key]);
264    }
265
266    #[test]
267    fn notifications_preserve_existing_serialization_and_deduplicate_dependencies() {
268        let original: Storage = serde_json::from_value(serde_json::json!({
269            "id": "objects", "publicRead": false, "versioning": false,
270            "lifecycleRules": [], "corsAllowedOrigins": []
271        }))
272        .unwrap();
273        assert!(original.notifications.is_empty());
274        assert_eq!(
275            serde_json::to_value(&original).unwrap(),
276            serde_json::json!({
277                "id": "objects", "publicRead": false, "versioning": false,
278                "lifecycleRules": [], "corsAllowedOrigins": []
279            })
280        );
281        let key = ResourceRef::new(crate::Key::RESOURCE_TYPE, "key");
282        let queue = ResourceRef::new(crate::Queue::RESOURCE_TYPE, "work");
283        let rule = StorageNotification::builder().queue(queue.clone()).build();
284        let storage = Storage::new("objects".to_string())
285            .encryption_key(key.clone())
286            .notifications(vec![rule.clone(), rule])
287            .build();
288        assert_eq!(storage.get_dependencies(), vec![key, queue]);
289        let encoded = serde_json::to_value(&storage).unwrap();
290        assert_eq!(
291            encoded["notifications"][0]["events"],
292            serde_json::json!(["created"])
293        );
294        assert_eq!(serde_json::from_value::<Storage>(encoded).unwrap(), storage);
295    }
296
297    #[test]
298    fn notification_filters_are_literal_case_sensitive_and_combined() {
299        let rule = StorageNotification::builder()
300            .queue(ResourceRef::new(crate::Queue::RESOURCE_TYPE, "work"))
301            .prefix("in/*/é/".to_string())
302            .suffix(".json".to_string())
303            .build();
304        assert!(rule.matches_key("in/*/é/object.json"));
305        for key in [
306            "in/a/é/object.json",
307            "in/*/e/object.json",
308            "in/*/é/object.JSON",
309            "other/object.json",
310            "in/*/é/object.json.tmp",
311        ] {
312            assert!(!rule.matches_key(key), "{key}");
313        }
314        let mut unfiltered = rule.clone();
315        unfiltered.prefix = None;
316        unfiltered.suffix = None;
317        assert!(unfiltered.matches_key("anything"));
318        unfiltered.suffix = Some(".json".to_string());
319        assert!(unfiltered.matches_key("any/path.json"));
320        assert!(!unfiltered.matches_key("any/path.txt"));
321        unfiltered.prefix = Some("same".to_string());
322        unfiltered.suffix = Some("same".to_string());
323        assert!(unfiltered.matches_key("same"));
324    }
325
326    #[test]
327    fn notification_events_default_to_created_and_reject_unknown_events() {
328        let rule: StorageNotification = serde_json::from_value(serde_json::json!({
329            "queue": { "type": "queue", "id": "work" }
330        }))
331        .unwrap();
332        assert_eq!(rule.events, vec![StorageNotificationEvent::Created]);
333        assert!(
334            serde_json::from_value::<StorageNotification>(serde_json::json!({
335                "queue": { "type": "queue", "id": "work" }, "events": ["deleted"]
336            }))
337            .is_err()
338        );
339    }
340}