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
11pub const AWS_ABORT_INCOMPLETE_UPLOAD_DAYS: i32 = 7;
16
17pub const AWS_LIFECYCLE_RULE_LIMIT: usize = 1000;
20
21pub const AWS_ABORT_INCOMPLETE_UPLOAD_RULE_ID: &str = "abort-incomplete-uploads";
24
25#[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 pub days: u32,
32 #[serde(default)]
34 pub prefix: Option<String>,
35}
36
37#[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 Created,
44}
45
46fn default_notification_events() -> Vec<StorageNotificationEvent> {
47 vec![StorageNotificationEvent::Created]
48}
49
50#[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 pub queue: ResourceRef,
59 #[serde(default = "default_notification_events")]
61 #[builder(default = default_notification_events())]
62 pub events: Vec<StorageNotificationEvent>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub prefix: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub suffix: Option<String>,
69}
70
71impl StorageNotification {
72 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#[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 #[builder(start_fn)]
93 pub id: String,
94
95 #[serde(default)]
98 #[builder(default)]
99 pub public_read: bool,
100
101 #[serde(default)]
104 #[builder(default)]
105 pub versioning: bool,
106
107 #[serde(default)]
110 #[builder(default)]
111 pub lifecycle_rules: Vec<LifecycleRule>,
112
113 #[serde(default)]
120 #[builder(default)]
121 pub cors_allowed_origins: Vec<String>,
122
123 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub encryption_key: Option<ResourceRef>,
133}
134
135impl Storage {
136 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("storage");
138
139 pub fn id(&self) -> &str {
141 &self.id
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
148#[serde(rename_all = "camelCase")]
149pub struct StorageOutputs {
150 pub bucket_name: String,
152 }
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
177impl 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 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(¬ification.queue) {
196 dependencies.push(notification.queue.clone());
197 }
198 }
199 dependencies
200 }
201
202 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
203 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}