Source code for infrahouse_core.aws.ecr_repository
"""
ECR Repository resource wrapper.
Provides ``exists`` / ``delete()`` support plus image queries via :class:`ECRImage`.
"""
from __future__ import annotations
from logging import getLogger
from typing import List
from botocore.exceptions import ClientError
from infrahouse_core.aws.base import AWSResource
LOG = getLogger(__name__)
[docs]
class ECRImage:
"""Represents a single image in an ECR repository.
Use :meth:`ECRRepository.get_image` to obtain instances.
Exactly one of ``tag`` or ``digest`` must be provided.
:param ecr_client: A boto3 ECR client.
:param repository_name: Name of the ECR repository.
:param tag: An image tag (e.g. ``"latest"``).
:param digest: An image digest (e.g. ``"sha256:abc..."``).
:raises ValueError: If neither or both of ``tag`` and ``digest`` are provided.
"""
def __init__(self, ecr_client, repository_name: str, tag: str = None, digest: str = None):
if tag and digest:
raise ValueError("Specify either tag or digest, not both.")
if not tag and not digest:
raise ValueError("Either tag or digest must be provided.")
self._client = ecr_client
self._repository_name = repository_name
self._tag = tag
self._digest = digest
@property
def exists(self) -> bool:
"""Return ``True`` if the image exists in the repository."""
try:
response = self._client.describe_images(
repositoryName=self._repository_name,
imageIds=[self.image_id],
)
return bool(response.get("imageDetails"))
except ClientError as err:
if err.response["Error"]["Code"] == "ImageNotFoundException":
return False
raise
@property
def tags(self) -> List[str]:
"""Return all tags for this image.
:rtype: list[str]
:return: List of image tags. Empty list if the image is not found.
"""
try:
response = self._client.describe_images(
repositoryName=self._repository_name,
imageIds=[self.image_id],
)
if response.get("imageDetails"):
return response["imageDetails"][0].get("imageTags", [])
return []
except ClientError as err:
if err.response["Error"]["Code"] == "ImageNotFoundException":
return []
raise
@property
def digest(self) -> str | None:
"""Return the image digest.
:rtype: str | None
"""
try:
response = self._client.describe_images(
repositoryName=self._repository_name,
imageIds=[self.image_id],
)
if response.get("imageDetails"):
return response["imageDetails"][0].get("imageDigest")
return None
except ClientError as err:
if err.response["Error"]["Code"] == "ImageNotFoundException":
return None
raise
[docs]
def tag_image(self, tag: str) -> None:
"""Apply an additional tag to this image.
Fetches the image manifest via ``batch_get_image`` and re-publishes it
with the new tag via ``put_image``. If the tag already points to the
same manifest, ``ImageAlreadyExistsException`` is caught silently.
:param tag: The tag to apply.
:raises ClientError: On AWS API errors (except ``ImageAlreadyExistsException``).
"""
response = self._client.batch_get_image(
repositoryName=self._repository_name,
imageIds=[self.image_id],
)
manifest = response["images"][0]["imageManifest"]
try:
self._client.put_image(
repositoryName=self._repository_name,
imageManifest=manifest,
imageTag=tag,
)
except ClientError as err:
if err.response["Error"]["Code"] == "ImageAlreadyExistsException":
LOG.debug("Tag %s already exists for this image in %s", tag, self._repository_name)
else:
raise
@property
def image_id(self) -> dict:
"""Return the ``imageIds`` element for boto3 ECR API calls.
:rtype: dict
"""
if self._digest:
return {"imageDigest": self._digest}
return {"imageTag": self._tag}
[docs]
class ECRRepository(AWSResource):
"""Wrapper around an ECR repository.
:param repository_name: Name of the ECR repository.
:param region: AWS region.
:param role_arn: IAM role ARN for cross-account access.
:param session: Pre-configured ``boto3.Session``.
"""
def __init__(self, repository_name, region=None, role_arn=None, session=None):
super().__init__(repository_name, "ecr", region=region, role_arn=role_arn, session=session)
@property
def repository_name(self) -> str:
"""Return the name of the repository.
:rtype: str
"""
return self._resource_id
@property
def exists(self) -> bool:
"""Return ``True`` if the repository exists."""
try:
self._client.describe_repositories(repositoryNames=[self._resource_id])
return True
except ClientError as err:
if err.response["Error"]["Code"] == "RepositoryNotFoundException":
return False
raise
@property
def repository_uri(self) -> str:
"""Return the full URI of the repository.
:rtype: str
:raises ClientError: If the repository does not exist.
"""
response = self._client.describe_repositories(repositoryNames=[self._resource_id])
return response["repositories"][0]["repositoryUri"]
[docs]
def get_image(self, tag: str = None, digest: str = None) -> ECRImage:
"""Return an :class:`ECRImage` for the given tag or digest.
Exactly one of ``tag`` or ``digest`` must be provided.
:param tag: An image tag (e.g. ``"latest"``).
:param digest: An image digest (e.g. ``"sha256:abc..."``).
:rtype: ECRImage
"""
return ECRImage(self._client, self._resource_id, tag=tag, digest=digest)
@property
def images(self) -> List[ECRImage]:
"""Return all images in the repository.
Paginates through ``describe_images`` to return all results.
:rtype: list[ECRImage]
"""
result = []
paginator = self._client.get_paginator("describe_images")
for page in paginator.paginate(repositoryName=self._resource_id):
for detail in page.get("imageDetails", []):
digest = detail.get("imageDigest")
if digest:
result.append(ECRImage(self._client, self._resource_id, digest=digest))
return result
[docs]
def delete(self) -> None:
"""Delete the repository and all its images.
Idempotent -- does nothing if the repository does not exist.
"""
try:
self._client.delete_repository(repositoryName=self._resource_id, force=True)
LOG.info("Deleted ECR repository %s", self._resource_id)
except ClientError as err:
if err.response["Error"]["Code"] == "RepositoryNotFoundException":
LOG.info("ECR repository %s does not exist.", self._resource_id)
else:
raise