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::sync::Arc;
18use std::time::Duration;
19
20use chrono::Utc;
21use tokio::sync::Mutex as AsyncMutex;
22
23use fakecloud_persistence::SnapshotStore;
24
25use crate::service::{evaluate_lifecycle_policy, EcrService};
26use crate::state::SharedEcrState;
27
28/// Default tick interval. AWS itself doesn't publish a guaranteed
29/// re-eval cadence; 5 minutes is a balance between picking up
30/// `sinceImagePushed` evictions promptly and not burning CPU walking
31/// idle accounts.
32pub const DEFAULT_TICK_INTERVAL: Duration = Duration::from_secs(300);
33
34/// Background task that periodically re-applies lifecycle policies.
35pub struct LifecycleTicker {
36    state: SharedEcrState,
37    interval: Duration,
38    /// Snapshot store + lock so a pruning tick is persisted. Without it the
39    /// ticker evicted images only in memory; on restart the snapshot (last
40    /// written by some unrelated mutating op, or never) resurrected them —
41    /// permanently if the policy had since been deleted. bug-audit
42    /// 2026-06-15, 4.6.
43    snapshot_store: Option<Arc<dyn SnapshotStore>>,
44    snapshot_lock: Arc<AsyncMutex<()>>,
45}
46
47impl LifecycleTicker {
48    pub fn new(state: SharedEcrState) -> Self {
49        Self {
50            state,
51            interval: DEFAULT_TICK_INTERVAL,
52            snapshot_store: None,
53            snapshot_lock: Arc::new(AsyncMutex::new(())),
54        }
55    }
56
57    /// Override the tick interval. Tests use a tiny value; production
58    /// uses the default.
59    pub fn with_interval(mut self, interval: Duration) -> Self {
60        self.interval = interval;
61        self
62    }
63
64    /// Wire the snapshot store + lock so a pruning tick persists. Pass the
65    /// same lock the [`EcrService`] uses so ticker and request-path saves
66    /// serialize against each other. bug-audit 4.6.
67    pub fn with_snapshot(
68        mut self,
69        store: Option<Arc<dyn SnapshotStore>>,
70        lock: Arc<AsyncMutex<()>>,
71    ) -> Self {
72        self.snapshot_store = store;
73        self.snapshot_lock = lock;
74        self
75    }
76
77    pub async fn run(self) {
78        let mut ticker = tokio::time::interval(self.interval);
79        // First tick fires immediately by default — skip it so we
80        // don't double-evaluate right after server start (the
81        // synchronous PutLifecyclePolicy path already evaluated).
82        ticker.tick().await;
83        loop {
84            ticker.tick().await;
85            // Persist whenever a tick changed state (pruned images and/or
86            // stamped last_evaluated_at); otherwise the eviction lives only in
87            // memory and is undone by the next snapshot load. bug-audit 4.6.
88            if tick_once(&self.state) {
89                EcrService::save_snapshot_with(
90                    self.state.clone(),
91                    self.snapshot_store.clone(),
92                    self.snapshot_lock.clone(),
93                )
94                .await;
95            }
96        }
97    }
98}
99
100/// Single pass over all accounts/repositories. Re-evaluates each
101/// lifecycle policy and applies the resulting prune set. Cheap when
102/// no policies are set: a read-only scan that bails before touching
103/// the write lock.
104///
105/// Returns `true` when the pass mutated state (evaluated at least one policy,
106/// which prunes images and/or stamps `lifecycle_policy_last_evaluated_at`), so
107/// the caller knows it must persist a snapshot. Returns `false` on the cheap
108/// no-policy early-out. bug-audit 4.6.
109pub fn tick_once(state: &SharedEcrState) -> bool {
110    // Collect (account_id, repo_name, policy) under the read lock so
111    // we don't hold the writer while parsing JSON. Doubles as the
112    // cheap precheck — when no repo has a policy, `plans` is empty
113    // and we bail before touching the write lock.
114    let plans: Vec<(String, String, String)> = {
115        let accounts = state.read();
116        let mut out: Vec<(String, String, String)> = Vec::new();
117        for (acct, s) in accounts.iter() {
118            for (name, repo) in s.repositories.iter() {
119                if let Some(policy) = repo.lifecycle_policy.as_ref() {
120                    out.push((acct.to_string(), name.clone(), policy.clone()));
121                }
122            }
123        }
124        out
125    };
126
127    if plans.is_empty() {
128        return false;
129    }
130
131    let mut accounts = state.write();
132    let now = Utc::now();
133    for (account, name, policy) in plans {
134        let Some(s) = accounts.get_mut(&account) else {
135            continue;
136        };
137        let Some(repo) = s.repositories.get_mut(&name) else {
138            continue;
139        };
140        let prune = evaluate_lifecycle_policy(repo, &policy);
141        if !prune.is_empty() {
142            tracing::info!(
143                repository = %name,
144                account = %account,
145                count = prune.len(),
146                "ECR lifecycle: pruning expired images on tick"
147            );
148            for digest in &prune {
149                repo.images.remove(digest);
150                repo.image_tags.retain(|_, d| d != digest);
151            }
152        }
153        repo.lifecycle_policy_last_evaluated_at = Some(now);
154    }
155    true
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::state::{EcrState, Image, Repository};
162    use chrono::Duration as ChronoDuration;
163    use fakecloud_core::multi_account::MultiAccountState;
164    use parking_lot::RwLock;
165    use std::sync::Arc;
166
167    const ACCOUNT: &str = "111111111111";
168
169    fn shared_state_with_repo(repo: Repository) -> SharedEcrState {
170        let mut mas: MultiAccountState<EcrState> =
171            MultiAccountState::new(ACCOUNT, "us-east-1", "http://fakecloud:4566");
172        let s = mas.get_or_create(ACCOUNT);
173        s.repositories.insert(repo.repository_name.clone(), repo);
174        Arc::new(RwLock::new(mas))
175    }
176
177    fn make_repo_with_old_image() -> Repository {
178        let arn = format!("arn:aws:ecr:us-east-1:{ACCOUNT}:repository/svc");
179        let mut repo = Repository::new("svc", arn, ACCOUNT, "fakecloud:4566");
180        repo.images.insert(
181            "sha256:old".to_string(),
182            Image {
183                image_digest: "sha256:old".to_string(),
184                image_manifest: String::new(),
185                image_manifest_media_type: String::new(),
186                artifact_media_type: None,
187                image_size_in_bytes: 0,
188                // Pushed 30 days ago.
189                image_pushed_at: Utc::now() - ChronoDuration::days(30),
190                last_recorded_pull_time: None,
191                image_status: "ACTIVE".to_string(),
192                last_archived_at: None,
193                last_activated_at: None,
194                last_in_use_at: None,
195                in_use_count: 0,
196            },
197        );
198        repo.image_tags
199            .insert("v1".to_string(), "sha256:old".to_string());
200        repo
201    }
202
203    #[test]
204    fn tick_once_no_policy_is_cheap_and_noop() {
205        let state = shared_state_with_repo(make_repo_with_old_image());
206        // No policy set -> no last_evaluated_at, no image removal.
207        tick_once(&state);
208        let accounts = state.read();
209        let repo = accounts
210            .get(ACCOUNT)
211            .unwrap()
212            .repositories
213            .get("svc")
214            .unwrap();
215        assert!(repo.lifecycle_policy_last_evaluated_at.is_none());
216        assert_eq!(repo.images.len(), 1);
217    }
218
219    #[test]
220    fn tick_once_prunes_and_stamps_last_evaluated_at() {
221        let mut repo = make_repo_with_old_image();
222        // Policy: prune images older than 7 days.
223        repo.lifecycle_policy = Some(
224            r#"{"rules":[{
225                "rulePriority":1,
226                "selection":{
227                    "tagStatus":"any",
228                    "countType":"sinceImagePushed",
229                    "countUnit":"days",
230                    "countNumber":7
231                }
232            }]}"#
233                .to_string(),
234        );
235        let state = shared_state_with_repo(repo);
236        tick_once(&state);
237        let accounts = state.read();
238        let repo = accounts
239            .get(ACCOUNT)
240            .unwrap()
241            .repositories
242            .get("svc")
243            .unwrap();
244        assert!(
245            repo.lifecycle_policy_last_evaluated_at.is_some(),
246            "tick should stamp last_evaluated_at"
247        );
248        assert!(
249            repo.images.is_empty(),
250            "old image should have been pruned by tick"
251        );
252        assert!(
253            repo.image_tags.is_empty(),
254            "tags pointing at pruned image should be gone"
255        );
256    }
257
258    // bug-audit 2026-06-15, 4.6: a pruning tick must be persisted. Before the
259    // fix the ticker evicted images only in memory, so a snapshot load (on
260    // restart) resurrected them. Here we prune, persist via the same writer the
261    // ticker uses, then load the snapshot and confirm the image is gone.
262    #[tokio::test]
263    async fn pruning_tick_is_persisted_and_survives_reload() {
264        use fakecloud_persistence::{DiskSnapshotStore, SnapshotStore};
265        use std::sync::Arc;
266        use tokio::sync::Mutex as AsyncMutex;
267
268        let mut repo = make_repo_with_old_image();
269        repo.lifecycle_policy = Some(
270            r#"{"rules":[{
271                "rulePriority":1,
272                "selection":{
273                    "tagStatus":"any",
274                    "countType":"sinceImagePushed",
275                    "countUnit":"days",
276                    "countNumber":7
277                }
278            }]}"#
279                .to_string(),
280        );
281        let state = shared_state_with_repo(repo);
282
283        let dir = tempfile::tempdir().unwrap();
284        let store: Arc<dyn SnapshotStore> =
285            Arc::new(DiskSnapshotStore::new(dir.path().join("snapshot.json")));
286        let lock = Arc::new(AsyncMutex::new(()));
287
288        // A pruning tick reports it mutated state; persist exactly as run() does.
289        assert!(tick_once(&state), "tick should report it pruned");
290        EcrService::save_snapshot_with(state.clone(), Some(store.clone()), lock.clone()).await;
291
292        let bytes = store.load().unwrap().expect("snapshot written");
293        let snapshot: crate::state::EcrSnapshot = serde_json::from_slice(&bytes).unwrap();
294        let accounts = snapshot.accounts.expect("multi-account snapshot");
295        let repo = accounts
296            .get(ACCOUNT)
297            .unwrap()
298            .repositories
299            .get("svc")
300            .unwrap();
301        assert!(
302            repo.images.is_empty(),
303            "pruned image must stay gone after reload, not resurrect"
304        );
305        assert!(repo.lifecycle_policy_last_evaluated_at.is_some());
306    }
307
308    #[test]
309    fn tick_once_updates_timestamp_even_when_nothing_to_prune() {
310        let mut repo = make_repo_with_old_image();
311        // Policy that matches but keeps the image (countNumber=10
312        // covers the only image).
313        repo.lifecycle_policy = Some(
314            r#"{"rules":[{
315                "rulePriority":1,
316                "selection":{
317                    "tagStatus":"tagged",
318                    "countType":"imageCountMoreThan",
319                    "countNumber":10
320                }
321            }]}"#
322                .to_string(),
323        );
324        let state = shared_state_with_repo(repo);
325        tick_once(&state);
326        let accounts = state.read();
327        let repo = accounts
328            .get(ACCOUNT)
329            .unwrap()
330            .repositories
331            .get("svc")
332            .unwrap();
333        assert!(repo.lifecycle_policy_last_evaluated_at.is_some());
334        assert_eq!(repo.images.len(), 1);
335    }
336}