Skip to main content

fakecloud_s3/
lifecycle.rs

1use std::time::Duration;
2
3use chrono::{NaiveDate, Utc};
4
5use crate::state::SharedS3State;
6
7/// Background task that processes S3 lifecycle rules.
8///
9/// Every 60 seconds, iterates all buckets with lifecycle configurations,
10/// parses the lifecycle XML, and:
11/// - Deletes objects matching expiration rules (by Days or Date)
12/// - Updates storage class for objects matching transition rules
13pub struct LifecycleProcessor {
14    state: SharedS3State,
15}
16
17impl LifecycleProcessor {
18    pub fn new(state: SharedS3State) -> Self {
19        Self { state }
20    }
21
22    pub async fn run(self) {
23        let mut interval = tokio::time::interval(Duration::from_secs(60));
24
25        loop {
26            interval.tick().await;
27            self.tick();
28        }
29    }
30
31    pub fn tick(&self) {
32        let now = Utc::now();
33        let today = now.date_naive();
34
35        // Collect bucket names and their lifecycle configs (to avoid holding lock during processing)
36        let bucket_configs: Vec<(String, String)> = {
37            let state = self.state.read();
38            state
39                .buckets
40                .values()
41                .filter_map(|b| {
42                    b.lifecycle_config
43                        .as_ref()
44                        .map(|cfg| (b.name.clone(), cfg.clone()))
45                })
46                .collect()
47        };
48
49        for (bucket_name, config_xml) in bucket_configs {
50            let rules = match parse_lifecycle_rules(&config_xml) {
51                Some(r) => r,
52                None => continue,
53            };
54
55            for rule in &rules {
56                if rule.status != "Enabled" {
57                    continue;
58                }
59
60                self.process_rule(&bucket_name, rule, today);
61            }
62        }
63    }
64
65    fn process_rule(&self, bucket_name: &str, rule: &LifecycleRule, today: NaiveDate) {
66        let mut state = self.state.write();
67        let bucket = match state.buckets.get_mut(bucket_name) {
68            Some(b) => b,
69            None => return,
70        };
71
72        // Collect keys to expire
73        let mut keys_to_delete: Vec<String> = Vec::new();
74        // Collect keys to transition (key, new_storage_class)
75        let mut keys_to_transition: Vec<(String, String)> = Vec::new();
76
77        for (key, obj) in bucket.objects.iter() {
78            // Check prefix filter
79            if let Some(ref prefix) = rule.prefix {
80                if !prefix.is_empty() && !key.starts_with(prefix) {
81                    continue;
82                }
83            }
84
85            // Check tag filter
86            if let Some(ref tag_filter) = rule.tag_filter {
87                let matches = obj
88                    .tags
89                    .get(&tag_filter.key)
90                    .map(|v| v == &tag_filter.value)
91                    .unwrap_or(false);
92                if !matches {
93                    continue;
94                }
95            }
96
97            // Check expiration by Days
98            if let Some(days) = rule.expiration_days {
99                let age = today
100                    .signed_duration_since(obj.last_modified.date_naive())
101                    .num_days();
102                if age >= days as i64 {
103                    keys_to_delete.push(key.clone());
104                    continue;
105                }
106            }
107
108            // Check expiration by Date
109            if let Some(ref date) = rule.expiration_date {
110                if &today >= date {
111                    keys_to_delete.push(key.clone());
112                    continue;
113                }
114            }
115
116            // Check transition by Days
117            for transition in &rule.transitions {
118                let should_transition = if let Some(days) = transition.days {
119                    let age = today
120                        .signed_duration_since(obj.last_modified.date_naive())
121                        .num_days();
122                    age >= days as i64
123                } else if let Some(ref date) = transition.date {
124                    &today >= date
125                } else {
126                    false
127                };
128
129                if should_transition && obj.storage_class != transition.storage_class {
130                    keys_to_transition.push((key.clone(), transition.storage_class.clone()));
131                    break; // Only apply first matching transition
132                }
133            }
134        }
135
136        // Apply deletions
137        if !keys_to_delete.is_empty() {
138            tracing::info!(
139                bucket = %bucket_name,
140                count = keys_to_delete.len(),
141                "S3 lifecycle: expiring objects"
142            );
143            for key in &keys_to_delete {
144                bucket.objects.remove(key);
145            }
146        }
147
148        // Apply transitions
149        if !keys_to_transition.is_empty() {
150            tracing::info!(
151                bucket = %bucket_name,
152                count = keys_to_transition.len(),
153                "S3 lifecycle: transitioning object storage classes"
154            );
155            for (key, new_class) in &keys_to_transition {
156                if let Some(obj) = bucket.objects.get_mut(key) {
157                    obj.storage_class = new_class.clone();
158                }
159            }
160        }
161    }
162}
163
164/// A parsed lifecycle rule.
165struct LifecycleRule {
166    status: String,
167    prefix: Option<String>,
168    tag_filter: Option<TagFilter>,
169    expiration_days: Option<u32>,
170    expiration_date: Option<NaiveDate>,
171    transitions: Vec<Transition>,
172}
173
174struct TagFilter {
175    key: String,
176    value: String,
177}
178
179struct Transition {
180    days: Option<u32>,
181    date: Option<NaiveDate>,
182    storage_class: String,
183}
184
185/// Parse lifecycle configuration XML into rules.
186fn parse_lifecycle_rules(xml: &str) -> Option<Vec<LifecycleRule>> {
187    let mut rules = Vec::new();
188    let mut remaining = xml;
189
190    while let Some(rule_start) = remaining.find("<Rule>") {
191        let after = &remaining[rule_start + 6..];
192        let rule_end = after.find("</Rule>")?;
193        let rule_body = &after[..rule_end];
194
195        let status = extract_tag(rule_body, "Status").unwrap_or_default();
196
197        // Parse prefix — can be at rule level or inside <Filter>
198        let prefix = if let Some(filter_body) = extract_block(rule_body, "Filter") {
199            // Check for <Prefix> inside Filter
200            let filter_prefix = extract_tag(filter_body, "Prefix");
201            // Also check for <And><Prefix> pattern
202            if filter_prefix.is_some() {
203                filter_prefix
204            } else if let Some(and_body) = extract_block(filter_body, "And") {
205                extract_tag(and_body, "Prefix")
206            } else {
207                None
208            }
209        } else {
210            extract_tag(rule_body, "Prefix")
211        };
212
213        // Parse tag filter from <Filter><Tag> or <Filter><And><Tag>
214        let tag_filter = if let Some(filter_body) = extract_block(rule_body, "Filter") {
215            parse_tag_filter(filter_body)
216        } else {
217            None
218        };
219
220        // Parse expiration
221        let (expiration_days, expiration_date) =
222            if let Some(exp_body) = extract_block(rule_body, "Expiration") {
223                let days = extract_tag(exp_body, "Days").and_then(|s| s.parse::<u32>().ok());
224                let date = extract_tag(exp_body, "Date").and_then(|s| parse_date(&s));
225                (days, date)
226            } else {
227                (None, None)
228            };
229
230        // Parse transitions
231        let mut transitions = Vec::new();
232        let mut trans_remaining = rule_body;
233        while let Some(t_start) = trans_remaining.find("<Transition>") {
234            let t_after = &trans_remaining[t_start + 12..];
235            if let Some(t_end) = t_after.find("</Transition>") {
236                let t_body = &t_after[..t_end];
237                let days = extract_tag(t_body, "Days").and_then(|s| s.parse::<u32>().ok());
238                let date = extract_tag(t_body, "Date").and_then(|s| parse_date(&s));
239                let storage_class =
240                    extract_tag(t_body, "StorageClass").unwrap_or_else(|| "GLACIER".to_string());
241                transitions.push(Transition {
242                    days,
243                    date,
244                    storage_class,
245                });
246                trans_remaining = &t_after[t_end + 13..];
247            } else {
248                break;
249            }
250        }
251
252        rules.push(LifecycleRule {
253            status,
254            prefix,
255            tag_filter,
256            expiration_days,
257            expiration_date,
258            transitions,
259        });
260
261        remaining = &after[rule_end + 7..];
262    }
263
264    Some(rules)
265}
266
267/// Extract text content of a simple XML tag, e.g. `<Days>30</Days>` -> "30".
268fn extract_tag(body: &str, tag: &str) -> Option<String> {
269    let open = format!("<{tag}>");
270    let close = format!("</{tag}>");
271    let start = body.find(&open)?;
272    let content_start = start + open.len();
273    let end = body[content_start..].find(&close)?;
274    Some(body[content_start..content_start + end].trim().to_string())
275}
276
277/// Extract the body of a block element, e.g. `<Filter>...</Filter>` -> "...".
278fn extract_block<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
279    let open = format!("<{tag}>");
280    let close = format!("</{tag}>");
281    let start = body.find(&open)?;
282    let content_start = start + open.len();
283    let end = body[content_start..].find(&close)?;
284    Some(&body[content_start..content_start + end])
285}
286
287fn parse_tag_filter(filter_body: &str) -> Option<TagFilter> {
288    // Try direct <Tag> inside <Filter>
289    if let Some(tag_body) = extract_block(filter_body, "Tag") {
290        let key = extract_tag(tag_body, "Key")?;
291        let value = extract_tag(tag_body, "Value").unwrap_or_default();
292        return Some(TagFilter { key, value });
293    }
294    // Try <And><Tag> inside <Filter>
295    if let Some(and_body) = extract_block(filter_body, "And") {
296        if let Some(tag_body) = extract_block(and_body, "Tag") {
297            let key = extract_tag(tag_body, "Key")?;
298            let value = extract_tag(tag_body, "Value").unwrap_or_default();
299            return Some(TagFilter { key, value });
300        }
301    }
302    None
303}
304
305/// Parse a date string like "2024-01-01" or "2024-01-01T00:00:00.000Z".
306fn parse_date(s: &str) -> Option<NaiveDate> {
307    // Try YYYY-MM-DD first
308    if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
309        return Some(d);
310    }
311    // Try ISO 8601 with time
312    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
313        return Some(dt.date_naive());
314    }
315    // Try with T and Z suffix
316    if let Some(date_part) = s.split('T').next() {
317        if let Ok(d) = NaiveDate::parse_from_str(date_part, "%Y-%m-%d") {
318            return Some(d);
319        }
320    }
321    None
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn parse_expiration_days_rule() {
330        let xml = r#"<LifecycleConfiguration>
331            <Rule>
332                <Filter><Prefix>logs/</Prefix></Filter>
333                <Status>Enabled</Status>
334                <Expiration><Days>30</Days></Expiration>
335            </Rule>
336        </LifecycleConfiguration>"#;
337
338        let rules = parse_lifecycle_rules(xml).unwrap();
339        assert_eq!(rules.len(), 1);
340        assert_eq!(rules[0].status, "Enabled");
341        assert_eq!(rules[0].prefix.as_deref(), Some("logs/"));
342        assert_eq!(rules[0].expiration_days, Some(30));
343    }
344
345    #[test]
346    fn parse_expiration_date_rule() {
347        let xml = r#"<LifecycleConfiguration>
348            <Rule>
349                <Filter><Prefix></Prefix></Filter>
350                <Status>Enabled</Status>
351                <Expiration><Date>2024-06-01</Date></Expiration>
352            </Rule>
353        </LifecycleConfiguration>"#;
354
355        let rules = parse_lifecycle_rules(xml).unwrap();
356        assert_eq!(rules.len(), 1);
357        assert_eq!(
358            rules[0].expiration_date,
359            Some(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap())
360        );
361    }
362
363    #[test]
364    fn parse_transition_rule() {
365        let xml = r#"<LifecycleConfiguration>
366            <Rule>
367                <Filter><Prefix>archive/</Prefix></Filter>
368                <Status>Enabled</Status>
369                <Transition>
370                    <Days>90</Days>
371                    <StorageClass>GLACIER</StorageClass>
372                </Transition>
373                <Transition>
374                    <Days>365</Days>
375                    <StorageClass>DEEP_ARCHIVE</StorageClass>
376                </Transition>
377            </Rule>
378        </LifecycleConfiguration>"#;
379
380        let rules = parse_lifecycle_rules(xml).unwrap();
381        assert_eq!(rules.len(), 1);
382        assert_eq!(rules[0].transitions.len(), 2);
383        assert_eq!(rules[0].transitions[0].days, Some(90));
384        assert_eq!(rules[0].transitions[0].storage_class, "GLACIER");
385        assert_eq!(rules[0].transitions[1].days, Some(365));
386        assert_eq!(rules[0].transitions[1].storage_class, "DEEP_ARCHIVE");
387    }
388
389    #[test]
390    fn parse_disabled_rule() {
391        let xml = r#"<LifecycleConfiguration>
392            <Rule>
393                <Filter><Prefix></Prefix></Filter>
394                <Status>Disabled</Status>
395                <Expiration><Days>1</Days></Expiration>
396            </Rule>
397        </LifecycleConfiguration>"#;
398
399        let rules = parse_lifecycle_rules(xml).unwrap();
400        assert_eq!(rules.len(), 1);
401        assert_eq!(rules[0].status, "Disabled");
402    }
403
404    #[test]
405    fn parse_tag_filter_rule() {
406        let xml = r#"<LifecycleConfiguration>
407            <Rule>
408                <Filter>
409                    <Tag><Key>env</Key><Value>test</Value></Tag>
410                </Filter>
411                <Status>Enabled</Status>
412                <Expiration><Days>7</Days></Expiration>
413            </Rule>
414        </LifecycleConfiguration>"#;
415
416        let rules = parse_lifecycle_rules(xml).unwrap();
417        assert_eq!(rules.len(), 1);
418        let tag = rules[0].tag_filter.as_ref().unwrap();
419        assert_eq!(tag.key, "env");
420        assert_eq!(tag.value, "test");
421    }
422
423    #[test]
424    fn parse_multiple_rules() {
425        let xml = r#"<LifecycleConfiguration>
426            <Rule>
427                <Filter><Prefix>a/</Prefix></Filter>
428                <Status>Enabled</Status>
429                <Expiration><Days>10</Days></Expiration>
430            </Rule>
431            <Rule>
432                <Filter><Prefix>b/</Prefix></Filter>
433                <Status>Enabled</Status>
434                <Expiration><Days>20</Days></Expiration>
435            </Rule>
436        </LifecycleConfiguration>"#;
437
438        let rules = parse_lifecycle_rules(xml).unwrap();
439        assert_eq!(rules.len(), 2);
440        assert_eq!(rules[0].prefix.as_deref(), Some("a/"));
441        assert_eq!(rules[0].expiration_days, Some(10));
442        assert_eq!(rules[1].prefix.as_deref(), Some("b/"));
443        assert_eq!(rules[1].expiration_days, Some(20));
444    }
445}