1use super::EcrClient;
5use crate::pagination::{cap_results, Pagination, StopReason};
6use anyhow::{anyhow, ensure, Result};
7use aws_sdk_ecr::error::ProvideErrorMetadata;
8use aws_sdk_ecr::types::ImageIdentifier;
9use tokio::time::{timeout_at, Duration, Instant};
10
11const ENTRY_LIMIT: usize = 1_000;
12const PAGE_LIMIT: usize = 100;
13const TIMEOUT: Duration = Duration::from_secs(30);
14const MISSING: &str = "-";
15
16#[derive(Debug)]
18pub struct ImageDetails {
19 pub tags: Vec<String>,
20 pub digest: String,
21 pub artifact_type: String,
22 pub manifest_type: String,
23 pub pushed_at: String,
24 pub size_bytes: Option<i64>,
25 pub last_pull_time: String,
26 pub image_status: String,
27}
28
29#[derive(Debug)]
33pub struct DetailReport {
34 pub entries: Vec<Vec<(String, String)>>,
35 pub warning: Option<String>,
36}
37
38impl EcrClient {
39 pub async fn image_details(&self, repository: &str, digest: &str) -> Result<ImageDetails> {
43 ensure!(!repository.is_empty(), "Missing ECR repository name");
44 ensure!(
45 !digest.is_empty(),
46 "Missing image digest; refresh the image list"
47 );
48 let client = self.config.ecr_client();
49 let deadline = Instant::now() + TIMEOUT;
50 let result = timeout_at(
51 deadline,
52 client
53 .describe_images()
54 .repository_name(repository)
55 .image_ids(ImageIdentifier::builder().image_digest(digest).build())
56 .send(),
57 )
58 .await;
59 let response = match result {
60 Ok(Ok(response)) => response,
61 Ok(Err(error)) => {
62 return Err(anyhow!("Image metadata unavailable: {}", service(error)))
63 }
64 Err(_) => {
65 return Err(anyhow!(
66 "Image metadata unavailable: request timed out after 30 seconds"
67 ))
68 }
69 };
70 let image = response
71 .image_details
72 .unwrap_or_default()
73 .into_iter()
74 .find(|image| image.image_digest.as_deref() == Some(digest))
75 .ok_or_else(|| anyhow!("No image found for digest {digest} in {repository}"))?;
76 let returned = image.image_digest.unwrap_or_default();
79 ensure!(
80 returned == digest,
81 "ECR returned digest {returned} for requested {digest}"
82 );
83 Ok(ImageDetails {
84 tags: image.image_tags.unwrap_or_default(),
85 digest: returned,
86 artifact_type: image.artifact_media_type.unwrap_or_else(|| MISSING.into()),
87 manifest_type: image
88 .image_manifest_media_type
89 .unwrap_or_else(|| MISSING.into()),
90 pushed_at: image
91 .image_pushed_at
92 .map(timestamp)
93 .unwrap_or_else(|| MISSING.into()),
94 size_bytes: image.image_size_in_bytes,
95 last_pull_time: image
96 .last_recorded_pull_time
97 .map(timestamp)
98 .unwrap_or_else(|| MISSING.into()),
99 image_status: image
100 .image_status
101 .map(|s| s.as_str().to_string())
102 .unwrap_or_else(|| MISSING.into()),
103 })
104 }
105
106 pub async fn image_referrers(&self, repository: &str, digest: &str) -> Result<DetailReport> {
109 let (repository, digest) = validate(repository, digest)?;
110 let client = self.config.ecr_client();
111 let deadline = Instant::now() + TIMEOUT;
112 let mut report = DetailReport {
113 entries: Vec::new(),
114 warning: None,
115 };
116 let mut token: Option<String> = None;
117 let mut pagination = Pagination::new(PAGE_LIMIT);
118 for page in 0..PAGE_LIMIT {
119 let result = timeout_at(
120 deadline,
121 client
122 .list_image_referrers()
123 .repository_name(repository)
124 .subject_id(
125 aws_sdk_ecr::types::SubjectIdentifier::builder()
126 .image_digest(digest)
127 .build()
128 .map_err(|e| anyhow!("Invalid subject digest: {e}"))?,
129 )
130 .max_results(50)
131 .set_next_token(token.clone())
132 .send(),
133 )
134 .await;
135 let response = match page_result(result, page, &mut report) {
136 Some(response) => response,
137 None => break,
138 };
139 for referrer in response.referrers.unwrap_or_default() {
140 let kind = referrer
142 .artifact_type
143 .filter(|t| !t.is_empty())
144 .unwrap_or_else(|| nonempty(referrer.media_type));
145 report.entries.push(vec![
146 ("Referrer digest".into(), nonempty(referrer.digest)),
147 ("Type".into(), kind),
148 ]);
149 }
150 if cap_results(
151 &mut report.entries,
152 ENTRY_LIMIT,
153 response.next_token.as_deref(),
154 ) {
155 report.warning = Some(format!("Partial results: limited to {ENTRY_LIMIT} entries"));
156 break;
157 }
158 match pagination.advance(response.next_token) {
159 Ok(Some(next)) => token = Some(next),
160 Ok(None) => break,
161 Err(reason) => {
162 report.warning = Some(match reason {
163 StopReason::RepeatedToken => {
164 "Partial results: repeated pagination token".into()
165 }
166 StopReason::ResultLimit => {
167 format!("Partial results: limited to {ENTRY_LIMIT} entries")
168 }
169 StopReason::PageLimit => {
170 format!("Partial results: stopped after {PAGE_LIMIT} pages")
171 }
172 });
173 break;
174 }
175 }
176 }
177 Ok(report)
178 }
179
180 pub async fn image_replication(&self, repository: &str, digest: &str) -> Result<DetailReport> {
183 let (repository, digest) = validate(repository, digest)?;
184 self.replication_for(
185 repository,
186 ImageIdentifier::builder().image_digest(digest).build(),
187 )
188 .await
189 }
190
191 pub async fn image_replication_by_tag(
195 &self,
196 repository: &str,
197 tag: &str,
198 ) -> Result<DetailReport> {
199 ensure!(!repository.is_empty(), "Missing ECR repository name");
200 ensure!(!tag.is_empty(), "Missing image tag");
201 self.replication_for(
202 repository,
203 ImageIdentifier::builder().image_tag(tag).build(),
204 )
205 .await
206 }
207
208 async fn replication_for(
209 &self,
210 repository: &str,
211 image_id: ImageIdentifier,
212 ) -> Result<DetailReport> {
213 let client = self.config.ecr_client();
214 let deadline = Instant::now() + TIMEOUT;
215 let mut report = DetailReport {
216 entries: Vec::new(),
217 warning: None,
218 };
219 let result = timeout_at(
220 deadline,
221 client
222 .describe_image_replication_status()
223 .repository_name(repository)
224 .image_id(image_id)
225 .send(),
226 )
227 .await;
228 let response = match page_result(result, 0, &mut report) {
230 Some(response) => response,
231 None => return Ok(report),
232 };
233 for status in response.replication_statuses.unwrap_or_default() {
234 report.entries.push(vec![
235 (
236 "Target account".into(),
237 status.registry_id.map(nonempty).unwrap_or_else(dash),
238 ),
239 (
240 "Region".into(),
241 status.region.map(nonempty).unwrap_or_else(dash),
242 ),
243 (
244 "Status".into(),
245 status
246 .status
247 .map(|s| s.as_str().to_string())
248 .unwrap_or_else(dash),
249 ),
250 (
251 "Error".into(),
252 status.failure_code.map(nonempty).unwrap_or_else(dash),
253 ),
254 ]);
255 }
256 Ok(report)
257 }
258
259 pub async fn image_signing(&self, repository: &str, digest: &str) -> Result<DetailReport> {
262 let (repository, digest) = validate(repository, digest)?;
263 let client = self.config.ecr_client();
264 let deadline = Instant::now() + TIMEOUT;
265 let mut report = DetailReport {
266 entries: Vec::new(),
267 warning: None,
268 };
269 let result = timeout_at(
270 deadline,
271 client
272 .describe_image_signing_status()
273 .repository_name(repository)
274 .image_id(ImageIdentifier::builder().image_digest(digest).build())
275 .send(),
276 )
277 .await;
278 let response = match page_result(result, 0, &mut report) {
280 Some(response) => response,
281 None => return Ok(report),
282 };
283 for status in response.signing_statuses.unwrap_or_default() {
284 report.entries.push(vec![
285 (
286 "Signer profile".into(),
287 status
288 .signing_profile_arn
289 .map(nonempty)
290 .unwrap_or_else(dash),
291 ),
292 (
293 "Status".into(),
294 status
295 .status
296 .map(|s| s.as_str().to_string())
297 .unwrap_or_else(dash),
298 ),
299 (
300 "Failure reason".into(),
301 status.failure_reason.map(nonempty).unwrap_or_else(dash),
302 ),
303 (
304 "Failure code".into(),
305 status.failure_code.map(nonempty).unwrap_or_else(dash),
306 ),
307 ]);
308 }
309 Ok(report)
310 }
311}
312
313fn validate<'a>(repository: &'a str, digest: &'a str) -> Result<(&'a str, &'a str)> {
314 ensure!(!repository.is_empty(), "Missing ECR repository name");
315 ensure!(
316 !digest.is_empty(),
317 "Missing image digest; refresh the image list"
318 );
319 Ok((repository, digest))
320}
321
322fn page_result<T, E>(
326 result: Result<Result<T, E>, tokio::time::error::Elapsed>,
327 page: usize,
328 report: &mut DetailReport,
329) -> Option<T>
330where
331 E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
332{
333 match result {
334 Ok(Ok(response)) => Some(response),
335 Ok(Err(error)) => {
336 let detail = service(error);
337 report.warning = Some(if page == 0 {
338 format!("Unavailable: {detail}")
339 } else {
340 format!("Partial results: {detail}")
341 });
342 None
343 }
344 Err(_) => {
345 report.warning = Some(if page == 0 {
346 "Unavailable: request timed out after 30 seconds".into()
347 } else {
348 "Partial results: request timed out after 30 seconds".into()
349 });
350 None
351 }
352 }
353}
354
355fn service<E>(error: E) -> String
356where
357 E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
358{
359 if let Some(code) = error.code() {
360 format!(
361 "{}: {}",
362 code,
363 error.message().unwrap_or("No details reported")
364 )
365 } else {
366 format!("{:#}", anyhow::Error::new(error))
367 }
368}
369
370fn timestamp(date: aws_smithy_types::DateTime) -> String {
371 date.fmt(aws_smithy_types::date_time::Format::DateTime)
372 .unwrap_or_default()
373}
374
375fn nonempty(value: String) -> String {
376 if value.is_empty() {
377 MISSING.into()
378 } else {
379 value
380 }
381}
382
383fn dash() -> String {
384 MISSING.into()
385}
386
387#[cfg(test)]
388mod tests;