1use std::time::Duration;
2
3use chrono::{NaiveDate, Utc};
4
5use crate::state::SharedS3State;
6
7pub 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 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 let mut keys_to_delete: Vec<String> = Vec::new();
74 let mut keys_to_transition: Vec<(String, String)> = Vec::new();
76
77 for (key, obj) in bucket.objects.iter() {
78 if let Some(ref prefix) = rule.prefix {
80 if !prefix.is_empty() && !key.starts_with(prefix) {
81 continue;
82 }
83 }
84
85 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 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 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 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; }
133 }
134 }
135
136 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 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
164struct 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
185fn 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 let prefix = if let Some(filter_body) = extract_block(rule_body, "Filter") {
199 let filter_prefix = extract_tag(filter_body, "Prefix");
201 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 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 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 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
267fn 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
277fn 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 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 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
305fn parse_date(s: &str) -> Option<NaiveDate> {
307 if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
309 return Some(d);
310 }
311 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
313 return Some(dt.date_naive());
314 }
315 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}