fakecloud_ecr/
lifecycle_ticker.rs1use 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
28pub const DEFAULT_TICK_INTERVAL: Duration = Duration::from_secs(300);
33
34pub struct LifecycleTicker {
36 state: SharedEcrState,
37 interval: Duration,
38 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 pub fn with_interval(mut self, interval: Duration) -> Self {
60 self.interval = interval;
61 self
62 }
63
64 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 ticker.tick().await;
83 loop {
84 ticker.tick().await;
85 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
100pub fn tick_once(state: &SharedEcrState) -> bool {
110 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 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 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 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 #[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 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 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}