use super::EcrClient;
use crate::pagination::{cap_results, Pagination, StopReason};
use anyhow::{anyhow, ensure, Result};
use aws_sdk_ecr::error::ProvideErrorMetadata;
use aws_sdk_ecr::types::ImageIdentifier;
use tokio::time::{timeout_at, Duration, Instant};
const ENTRY_LIMIT: usize = 1_000;
const PAGE_LIMIT: usize = 100;
const TIMEOUT: Duration = Duration::from_secs(30);
const MISSING: &str = "-";
#[derive(Debug)]
pub struct ImageDetails {
pub tags: Vec<String>,
pub digest: String,
pub artifact_type: String,
pub manifest_type: String,
pub pushed_at: String,
pub size_bytes: Option<i64>,
pub last_pull_time: String,
pub image_status: String,
}
#[derive(Debug)]
pub struct DetailReport {
pub entries: Vec<Vec<(String, String)>>,
pub warning: Option<String>,
}
impl EcrClient {
pub async fn image_details(&self, repository: &str, digest: &str) -> Result<ImageDetails> {
ensure!(!repository.is_empty(), "Missing ECR repository name");
ensure!(
!digest.is_empty(),
"Missing image digest; refresh the image list"
);
let client = self.config.ecr_client();
let deadline = Instant::now() + TIMEOUT;
let result = timeout_at(
deadline,
client
.describe_images()
.repository_name(repository)
.image_ids(ImageIdentifier::builder().image_digest(digest).build())
.send(),
)
.await;
let response = match result {
Ok(Ok(response)) => response,
Ok(Err(error)) => {
return Err(anyhow!("Image metadata unavailable: {}", service(error)))
}
Err(_) => {
return Err(anyhow!(
"Image metadata unavailable: request timed out after 30 seconds"
))
}
};
let image = response
.image_details
.unwrap_or_default()
.into_iter()
.find(|image| image.image_digest.as_deref() == Some(digest))
.ok_or_else(|| anyhow!("No image found for digest {digest} in {repository}"))?;
let returned = image.image_digest.unwrap_or_default();
ensure!(
returned == digest,
"ECR returned digest {returned} for requested {digest}"
);
Ok(ImageDetails {
tags: image.image_tags.unwrap_or_default(),
digest: returned,
artifact_type: image.artifact_media_type.unwrap_or_else(|| MISSING.into()),
manifest_type: image
.image_manifest_media_type
.unwrap_or_else(|| MISSING.into()),
pushed_at: image
.image_pushed_at
.map(timestamp)
.unwrap_or_else(|| MISSING.into()),
size_bytes: image.image_size_in_bytes,
last_pull_time: image
.last_recorded_pull_time
.map(timestamp)
.unwrap_or_else(|| MISSING.into()),
image_status: image
.image_status
.map(|s| s.as_str().to_string())
.unwrap_or_else(|| MISSING.into()),
})
}
pub async fn image_referrers(&self, repository: &str, digest: &str) -> Result<DetailReport> {
let (repository, digest) = validate(repository, digest)?;
let client = self.config.ecr_client();
let deadline = Instant::now() + TIMEOUT;
let mut report = DetailReport {
entries: Vec::new(),
warning: None,
};
let mut token: Option<String> = None;
let mut pagination = Pagination::new(PAGE_LIMIT);
for page in 0..PAGE_LIMIT {
let result = timeout_at(
deadline,
client
.list_image_referrers()
.repository_name(repository)
.subject_id(
aws_sdk_ecr::types::SubjectIdentifier::builder()
.image_digest(digest)
.build()
.map_err(|e| anyhow!("Invalid subject digest: {e}"))?,
)
.max_results(50)
.set_next_token(token.clone())
.send(),
)
.await;
let response = match page_result(result, page, &mut report) {
Some(response) => response,
None => break,
};
for referrer in response.referrers.unwrap_or_default() {
let kind = referrer
.artifact_type
.filter(|t| !t.is_empty())
.unwrap_or_else(|| nonempty(referrer.media_type));
report.entries.push(vec![
("Referrer digest".into(), nonempty(referrer.digest)),
("Type".into(), kind),
]);
}
if cap_results(
&mut report.entries,
ENTRY_LIMIT,
response.next_token.as_deref(),
) {
report.warning = Some(format!("Partial results: limited to {ENTRY_LIMIT} entries"));
break;
}
match pagination.advance(response.next_token) {
Ok(Some(next)) => token = Some(next),
Ok(None) => break,
Err(reason) => {
report.warning = Some(match reason {
StopReason::RepeatedToken => {
"Partial results: repeated pagination token".into()
}
StopReason::ResultLimit => {
format!("Partial results: limited to {ENTRY_LIMIT} entries")
}
StopReason::PageLimit => {
format!("Partial results: stopped after {PAGE_LIMIT} pages")
}
});
break;
}
}
}
Ok(report)
}
pub async fn image_replication(&self, repository: &str, digest: &str) -> Result<DetailReport> {
let (repository, digest) = validate(repository, digest)?;
self.replication_for(
repository,
ImageIdentifier::builder().image_digest(digest).build(),
)
.await
}
pub async fn image_replication_by_tag(
&self,
repository: &str,
tag: &str,
) -> Result<DetailReport> {
ensure!(!repository.is_empty(), "Missing ECR repository name");
ensure!(!tag.is_empty(), "Missing image tag");
self.replication_for(
repository,
ImageIdentifier::builder().image_tag(tag).build(),
)
.await
}
async fn replication_for(
&self,
repository: &str,
image_id: ImageIdentifier,
) -> Result<DetailReport> {
let client = self.config.ecr_client();
let deadline = Instant::now() + TIMEOUT;
let mut report = DetailReport {
entries: Vec::new(),
warning: None,
};
let result = timeout_at(
deadline,
client
.describe_image_replication_status()
.repository_name(repository)
.image_id(image_id)
.send(),
)
.await;
let response = match page_result(result, 0, &mut report) {
Some(response) => response,
None => return Ok(report),
};
for status in response.replication_statuses.unwrap_or_default() {
report.entries.push(vec![
(
"Target account".into(),
status.registry_id.map(nonempty).unwrap_or_else(dash),
),
(
"Region".into(),
status.region.map(nonempty).unwrap_or_else(dash),
),
(
"Status".into(),
status
.status
.map(|s| s.as_str().to_string())
.unwrap_or_else(dash),
),
(
"Error".into(),
status.failure_code.map(nonempty).unwrap_or_else(dash),
),
]);
}
Ok(report)
}
pub async fn image_signing(&self, repository: &str, digest: &str) -> Result<DetailReport> {
let (repository, digest) = validate(repository, digest)?;
let client = self.config.ecr_client();
let deadline = Instant::now() + TIMEOUT;
let mut report = DetailReport {
entries: Vec::new(),
warning: None,
};
let result = timeout_at(
deadline,
client
.describe_image_signing_status()
.repository_name(repository)
.image_id(ImageIdentifier::builder().image_digest(digest).build())
.send(),
)
.await;
let response = match page_result(result, 0, &mut report) {
Some(response) => response,
None => return Ok(report),
};
for status in response.signing_statuses.unwrap_or_default() {
report.entries.push(vec![
(
"Signer profile".into(),
status
.signing_profile_arn
.map(nonempty)
.unwrap_or_else(dash),
),
(
"Status".into(),
status
.status
.map(|s| s.as_str().to_string())
.unwrap_or_else(dash),
),
(
"Failure reason".into(),
status.failure_reason.map(nonempty).unwrap_or_else(dash),
),
(
"Failure code".into(),
status.failure_code.map(nonempty).unwrap_or_else(dash),
),
]);
}
Ok(report)
}
}
fn validate<'a>(repository: &'a str, digest: &'a str) -> Result<(&'a str, &'a str)> {
ensure!(!repository.is_empty(), "Missing ECR repository name");
ensure!(
!digest.is_empty(),
"Missing image digest; refresh the image list"
);
Ok((repository, digest))
}
fn page_result<T, E>(
result: Result<Result<T, E>, tokio::time::error::Elapsed>,
page: usize,
report: &mut DetailReport,
) -> Option<T>
where
E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
{
match result {
Ok(Ok(response)) => Some(response),
Ok(Err(error)) => {
let detail = service(error);
report.warning = Some(if page == 0 {
format!("Unavailable: {detail}")
} else {
format!("Partial results: {detail}")
});
None
}
Err(_) => {
report.warning = Some(if page == 0 {
"Unavailable: request timed out after 30 seconds".into()
} else {
"Partial results: request timed out after 30 seconds".into()
});
None
}
}
}
fn service<E>(error: E) -> String
where
E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
{
if let Some(code) = error.code() {
format!(
"{}: {}",
code,
error.message().unwrap_or("No details reported")
)
} else {
format!("{:#}", anyhow::Error::new(error))
}
}
fn timestamp(date: aws_smithy_types::DateTime) -> String {
date.fmt(aws_smithy_types::date_time::Format::DateTime)
.unwrap_or_default()
}
fn nonempty(value: String) -> String {
if value.is_empty() {
MISSING.into()
} else {
value
}
}
fn dash() -> String {
MISSING.into()
}
#[cfg(test)]
mod tests;