From 097a60feddb9502c60dd35f3d974307f8425e93e Mon Sep 17 00:00:00 2001 From: Andrey Anshin Date: Sat, 17 Dec 2022 05:40:15 +0400 Subject: [PATCH] Add Amazon Elastic Container Registry (ECR) Hook (#28279) Co-authored-by: D. Ferruzzi Co-authored-by: Niko GitOrigin-RevId: 8e0df8881f22dd5c4c0ea71e7a9cd35b32889f47 --- airflow/providers/amazon/aws/hooks/ecr.py | 101 +++++++++++++++++ airflow/providers/amazon/provider.yaml | 7 ++ ...Elastic-Container-Registry_light-bg@4x.png | Bin 0 -> 11110 bytes docs/spelling_wordlist.txt | 1 + tests/providers/amazon/aws/hooks/test_ecr.py | 103 ++++++++++++++++++ 5 files changed, 212 insertions(+) create mode 100644 airflow/providers/amazon/aws/hooks/ecr.py create mode 100644 docs/integration-logos/aws/Amazon-Elastic-Container-Registry_light-bg@4x.png create mode 100644 tests/providers/amazon/aws/hooks/test_ecr.py diff --git a/airflow/providers/amazon/aws/hooks/ecr.py b/airflow/providers/amazon/aws/hooks/ecr.py new file mode 100644 index 00000000000..558f52be014 --- /dev/null +++ b/airflow/providers/amazon/aws/hooks/ecr.py @@ -0,0 +1,101 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import base64 +import logging +from dataclasses import dataclass +from datetime import datetime + +from airflow.providers.amazon.aws.hooks.base_aws import AwsBaseHook +from airflow.utils.log.secrets_masker import mask_secret + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class EcrCredentials: + """Helper (frozen dataclass) for storing temporary ECR credentials.""" + + username: str + password: str + proxy_endpoint: str + expires_at: datetime + + def __post_init__(self): + mask_secret(self.password) + logger.debug("Credentials to Amazon ECR %r expires at %s.", self.proxy_endpoint, self.expires_at) + + @property + def registry(self) -> str: + """Return registry in appropriate `docker login` format.""" + # https://github.com/docker/docker-py/issues/2256#issuecomment-824940506 + return self.proxy_endpoint.replace("https://", "") + + +class EcrHook(AwsBaseHook): + """ + Interact with Amazon Elastic Container Registry (ECR) + + Additional arguments (such as ``aws_conn_id``) may be specified and + are passed down to the underlying AwsBaseHook. + + .. seealso:: + :class:`~airflow.providers.amazon.aws.hooks.base_aws.AwsBaseHook` + """ + + def __init__(self, **kwargs): + kwargs["client_type"] = "ecr" + super().__init__(**kwargs) + + def get_temporary_credentials(self, registry_ids: list[str] | str | None = None) -> list[EcrCredentials]: + """Get temporary credentials for Amazon ECR. + + Return list of :class:`~airflow.providers.amazon.aws.hooks.ecr.EcrCredentials`, + obtained credentials valid for 12 hours. + + :param registry_ids: Either AWS Account ID or list of AWS Account IDs that are associated + with the registries from which credentials are obtained. If you do not specify a registry, + the default registry is assumed. + + .. seealso:: + - `boto3 ECR client get_authorization_token method `_. + """ + registry_ids = registry_ids or None + if isinstance(registry_ids, str): + registry_ids = [registry_ids] + + if registry_ids: + response = self.conn.get_authorization_token(registryIds=registry_ids) + else: + response = self.conn.get_authorization_token() + + creds = [] + for auth_data in response["authorizationData"]: + username, password = base64.b64decode(auth_data["authorizationToken"]).decode("utf-8").split(":") + creds.append( + EcrCredentials( + username=username, + password=password, + proxy_endpoint=auth_data["proxyEndpoint"], + expires_at=auth_data["expiresAt"], + ) + ) + + return creds diff --git a/airflow/providers/amazon/provider.yaml b/airflow/providers/amazon/provider.yaml index 7c5b557274f..3f7b793a1c2 100644 --- a/airflow/providers/amazon/provider.yaml +++ b/airflow/providers/amazon/provider.yaml @@ -96,6 +96,10 @@ integrations: how-to-guide: - /docs/apache-airflow-providers-amazon/operators/ec2.rst tags: [aws] + - integration-name: Amazon Elastic Container Registry (ECR) + external-doc-url: https://aws.amazon.com/ecr/ + logo: /integration-logos/aws/Amazon-Elastic-Container-Registry_light-bg@4x.png + tags: [aws] - integration-name: Amazon ECS external-doc-url: https://aws.amazon.com/ecs/ logo: /integration-logos/aws/Amazon-Elastic-Container-Service_light-bg@4x.png @@ -405,6 +409,9 @@ hooks: - integration-name: Amazon EC2 python-modules: - airflow.providers.amazon.aws.hooks.ec2 + - integration-name: Amazon Elastic Container Registry (ECR) + python-modules: + - airflow.providers.amazon.aws.hooks.ecr - integration-name: Amazon ECS python-modules: - airflow.providers.amazon.aws.hooks.ecs diff --git a/docs/integration-logos/aws/Amazon-Elastic-Container-Registry_light-bg@4x.png b/docs/integration-logos/aws/Amazon-Elastic-Container-Registry_light-bg@4x.png new file mode 100644 index 0000000000000000000000000000000000000000..5962c6d7b2bdc4da3c443d98406f04065cf46457 GIT binary patch literal 11110 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 HPrUvQf