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