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