Skip to main content

fakecloud_ecr/
lifecycle_ticker.rs

1//! Periodic re-evaluation of repository lifecycle policies.
2//!
3//! `PutLifecyclePolicy` runs the policy synchronously once at write
4//! time. AWS ECR also re-runs lifecycle rules on a recurring schedule
5//! so that `sinceImagePushed` time-based selections eventually evict
6//! aging images even when no new push triggers an evaluation. This
7//! ticker provides that re-run loop: every `interval` it walks all
8//! repositories that have a lifecycle policy, applies the prune set,
9//! and stamps `lifecycle_policy_last_evaluated_at`.
10//!
11//! When no repository has a lifecycle policy set the tick is a cheap
12//! read-only scan of `state` and exits without taking the write lock,
13//! so the loop costs almost nothing in idle setups.
14//!
15//! The ticker is wired up at server startup in `fakecloud-server` via
16//! `tokio::spawn(LifecycleTicker::new(state).run())`.
17use std::time::Duration;
18
19use chrono::Utc;
20
21use crate::service::evaluate_lifecycle_policy;
22use crate::state::SharedEcrState;
23
24/// Default tick interval. AWS itself doesn't publish a guaranteed
25/// re-eval cadence; 5 minutes is a balance between picking up
26/// `sinceImagePushed` evictions promptly and not burning CPU walking
27/// idle accounts.
28pub const DEFAULT_TICK_INTERVAL: Duration = Duration::from_secs(300);
29
30/// Background task that periodically re-applies lifecycle policies.
31pub struct LifecycleTicker {
32    state: SharedEcrState,
33    interval: Duration,
34}
35
36impl LifecycleTicker {
37    pub fn new(state: SharedEcrState) -> Self {
38        Self {
39            state,
40            interval: DEFAULT_TICK_INTERVAL,
41        }
42    }
43
44    /// Override the tick interval. Tests use a tiny value; production
45    /// uses the default.
46    pub fn with_interval(mut self, interval: Duration) -> Self {
47        self.interval = interval;
48        self
49    }
50
51    pub async fn run(self) {
52        let mut ticker = tokio::time::interval(self.interval);
53        // First tick fires immediately by default — skip it so we
54        // don't double-evaluate right after server start (the
55        // synchronous PutLifecyclePolicy path already evaluated).
56        ticker.tick().await;
57        loop {
58            ticker.tick().await;
59            tick_once(&self.state);
60        }
61    }
62}
63
64/// Single pass over all accounts/repositories. Re-evaluates each
65/// lifecycle policy and applies the resulting prune set. Cheap when
66/// no policies are set: a read-only scan that bails before touching
67/// the write lock.
68pub fn tick_once(state: &SharedEcrState) {
69    // Collect (account_id, repo_name, policy) under the read lock so
70    // we don't hold the writer while parsing JSON. Doubles as the
71    // cheap precheck — when no repo has a policy, `plans` is empty
72    // and we bail before touching the write lock.
73    let plans: Vec<(String, String, String)> = {
74        let accounts = state.read();
75        let mut out: Vec<(String, String, String)> = Vec::new();
76        for (acct, s) in accounts.iter() {
77            for (name, repo) in s.repositories.iter() {
78                if let Some(policy) = repo.lifecycle_policy.as_ref() {
79                    out.push((acct.to_string(), name.clone(), policy.clone()));
80                }
81            }
82        }
83        out
84    };
85
86    if plans.is_empty() {
87        return;
88    }
89
90    let mut accounts = state.write();
91    let now = Utc::now();
92    for (account, name, policy) in plans {
93        let Some(s) = accounts.get_mut(&account) else {
94            continue;
95        };
96        let Some(repo) = s.repositories.get_mut(&name) else {
97            continue;
98        };
99        let prune = evaluate_lifecycle_policy(repo, &policy);
100        if !prune.is_empty() {
101            tracing::info!(
102                repository = %name,
103                account = %account,
104                count = prune.len(),
105                "ECR lifecycle: pruning expired images on tick"
106            );
107            for digest in &prune {
108                repo.images.remove(digest);
109                repo.image_tags.retain(|_, d| d != digest);
110            }
111        }
112        repo.lifecycle_policy_last_evaluated_at = Some(now);
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::state::{EcrState, Image, Repository};
120    use chrono::Duration as ChronoDuration;
121    use fakecloud_core::multi_account::MultiAccountState;
122    use parking_lot::RwLock;
123    use std::sync::Arc;
124
125    const ACCOUNT: &str = "111111111111";
126
127    fn shared_state_with_repo(repo: Repository) -> SharedEcrState {
128        let mut mas: MultiAccountState<EcrState> =
129            MultiAccountState::new(ACCOUNT, "us-east-1", "http://fakecloud:4566");
130        let s = mas.get_or_create(ACCOUNT);
131        s.repositories.insert(repo.repository_name.clone(), repo);
132        Arc::new(RwLock::new(mas))
133    }
134
135    fn make_repo_with_old_image() -> Repository {
136        let arn = format!("arn:aws:ecr:us-east-1:{ACCOUNT}:repository/svc");
137        let mut repo = Repository::new("svc", arn, ACCOUNT, "fakecloud:4566");
138        repo.images.insert(
139            "sha256:old".to_string(),
140            Image {
141                image_digest: "sha256:old".to_string(),
142                image_manifest: String::new(),
143                image_manifest_media_type: String::new(),
144                artifact_media_type: None,
145                image_size_in_bytes: 0,
146                // Pushed 30 days ago.
147                image_pushed_at: Utc::now() - ChronoDuration::days(30),
148                last_recorded_pull_time: None,
149                image_status: "ACTIVE".to_string(),
150                last_archived_at: None,
151                last_activated_at: None,
152                last_in_use_at: None,
153                in_use_count: 0,
154            },
155        );
156        repo.image_tags
157            .insert("v1".to_string(), "sha256:old".to_string());
158        repo
159    }
160
161    #[test]
162    fn tick_once_no_policy_is_cheap_and_noop() {
163        let state = shared_state_with_repo(make_repo_with_old_image());
164        // No policy set -> no last_evaluated_at, no image removal.
165        tick_once(&state);
166        let accounts = state.read();
167        let repo = accounts
168            .get(ACCOUNT)
169            .unwrap()
170            .repositories
171            .get("svc")
172            .unwrap();
173        assert!(repo.lifecycle_policy_last_evaluated_at.is_none());
174        assert_eq!(repo.images.len(), 1);
175    }
176
177    #[test]
178    fn tick_once_prunes_and_stamps_last_evaluated_at() {
179        let mut repo = make_repo_with_old_image();
180        // Policy: prune images older than 7 days.
181        repo.lifecycle_policy = Some(
182            r#"{"rules":[{
183                "rulePriority":1,
184                "selection":{
185                    "tagStatus":"any",
186                    "countType":"sinceImagePushed",
187                    "countUnit":"days",
188                    "countNumber":7
189                }
190            }]}"#
191                .to_string(),
192        );
193        let state = shared_state_with_repo(repo);
194        tick_once(&state);
195        let accounts = state.read();
196        let repo = accounts
197            .get(ACCOUNT)
198            .unwrap()
199            .repositories
200            .get("svc")
201            .unwrap();
202        assert!(
203            repo.lifecycle_policy_last_evaluated_at.is_some(),
204            "tick should stamp last_evaluated_at"
205        );
206        assert!(
207            repo.images.is_empty(),
208            "old image should have been pruned by tick"
209        );
210        assert!(
211            repo.image_tags.is_empty(),
212            "tags pointing at pruned image should be gone"
213        );
214    }
215
216    #[test]
217    fn tick_once_updates_timestamp_even_when_nothing_to_prune() {
218        let mut repo = make_repo_with_old_image();
219        // Policy that matches but keeps the image (countNumber=10
220        // covers the only image).
221        repo.lifecycle_policy = Some(
222            r#"{"rules":[{
223                "rulePriority":1,
224                "selection":{
225                    "tagStatus":"tagged",
226                    "countType":"imageCountMoreThan",
227                    "countNumber":10
228                }
229            }]}"#
230                .to_string(),
231        );
232        let state = shared_state_with_repo(repo);
233        tick_once(&state);
234        let accounts = state.read();
235        let repo = accounts
236            .get(ACCOUNT)
237            .unwrap()
238            .repositories
239            .get("svc")
240            .unwrap();
241        assert!(repo.lifecycle_policy_last_evaluated_at.is_some());
242        assert_eq!(repo.images.len(), 1);
243    }
244}