fakecloud_ecr/
lifecycle_ticker.rs1use std::time::Duration;
18
19use chrono::Utc;
20
21use crate::service::evaluate_lifecycle_policy;
22use crate::state::SharedEcrState;
23
24pub const DEFAULT_TICK_INTERVAL: Duration = Duration::from_secs(300);
29
30pub 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 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 ticker.tick().await;
57 loop {
58 ticker.tick().await;
59 tick_once(&self.state);
60 }
61 }
62}
63
64pub fn tick_once(state: &SharedEcrState) {
69 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 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 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 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 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}