Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "feature",
"description": "Add container HTTP credentials resolver and `EcsContainer` chain provider."
}
1 change: 1 addition & 0 deletions packages/aws-credentials-http/NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
47 changes: 47 additions & 0 deletions packages/aws-credentials-http/README.md
Comment thread
jonathan343 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# aws-credentials-http

This package provides a container HTTP credential resolver and chain provider.

## Installation

```shell
uv pip install aws-credentials-http
```

Once installed, the provider registers itself with the SDK's modular credential
chain. When a client resolves credentials through the default chain, it
will attempt this source when the container credential environment variables
(`AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` or `AWS_CONTAINER_CREDENTIALS_FULL_URI`)
are set, unless a higher-precedence source resolves credentials first.

## Client Configuration

To use this resolver explicitly, set the `aws_credentials_identity_resolver`
property on a service client's config to a `ContainerCredentialsResolver`
instance:

```python
from aws_credentials_http import ContainerCredentialsResolver

service_client = ServiceClient(
config=ServiceClientConfig(
aws_credentials_identity_resolver=ContainerCredentialsResolver(),
)
)
```

## Standalone

The resolver can also be used on its own to fetch credentials directly:

```python
import asyncio

from aws_credentials_http import ContainerCredentialsResolver

async def main() -> None:
resolver = ContainerCredentialsResolver()
identity = await resolver.get_identity(properties={})

asyncio.run(main())
```
55 changes: 55 additions & 0 deletions packages/aws-credentials-http/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
[project]
name = "aws-credentials-http"
dynamic = ["version"]
requires-python = ">=3.12"
authors = [
{name = "Amazon Web Services"},
]
description = "HTTP endpoint credentials support for the AWS SDK for Python."
readme = "README.md"
license = {text = "Apache License 2.0"}
keywords = ["aws", "credentials", "http", "ecs", "eks", "sdk", "smithy"]
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"Natural Language :: English",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
"Topic :: Software Development :: Libraries",
]
dependencies = [
"smithy-aws-core~=0.8.0",
"smithy-core~=0.7.0",
"smithy-http[aiohttp]~=0.4.0",
]

[project.urls]
"Code" = "https://github.com/aws/aws-sdk-python/tree/develop/packages/aws-credentials-http/"
"Issue tracker" = "https://github.com/aws/aws-sdk-python/issues"

[project.entry-points."smithy_aws_core.identity.chain_providers"]
EcsContainer = "aws_credentials_http.providers:EcsContainerProvider"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.version]
path = "src/aws_credentials_http/__init__.py"

[tool.hatch.build]
exclude = [
"tests",
]

[tool.ruff]
src = ["src"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
__version__ = "0.0.0"

from .providers import EcsContainerProvider
from .resolvers import ContainerCredentialsResolver

__all__ = (
"ContainerCredentialsResolver",
"EcsContainerProvider",
)
102 changes: 102 additions & 0 deletions packages/aws-credentials-http/src/aws_credentials_http/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
import asyncio
import ipaddress
import json

from smithy_core import URI
from smithy_core.exceptions import SmithyIdentityError
from smithy_http import Field, Fields
from smithy_http.aio import HTTPRequest
from smithy_http.aio.interfaces import HTTPClient, HTTPResponse
from smithy_http.interfaces import HTTPRequestConfiguration

_CONTAINER_METADATA_IP = "169.254.170.2"
_CONTAINER_METADATA_ALLOWED_HOSTS = {
_CONTAINER_METADATA_IP,
"169.254.170.23",
"fd00:ec2::23",
"localhost",
}
_DEFAULT_TIMEOUT = 2
_DEFAULT_RETRIES = 3
_SLEEP_SECONDS = 1


class HttpCredentialsClient:
"""Retrieves AWS credentials from an HTTP credentials endpoint."""

def __init__(
self,
http_client: HTTPClient,
*,
timeout: int = _DEFAULT_TIMEOUT,
retries: int = _DEFAULT_RETRIES,
):
self._http_client = http_client
# TODO: Also apply this value as the connect timeout once smithy_http's
# HTTPRequestConfiguration supports it.
self._timeout = timeout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self._timeout seems never used. Should it be applied to the request, or should the parameter be dropped? Or do you want to to add a TODO?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why this wasn't applied to the request in the first place.

I confirmed we should apply the default timeout by looking at botocore. However, we need to apply the value to both the read timeout and connect timeout. Looks like HTTPRequestConfiguration only supports read_timeout for now which may be why it wasn't used.

I applied the value as the read_timeout in the request configuration and added a TODO to apply the value to connect_timeout once its supported.

self._retries = retries

async def get_credentials(self, uri: URI, fields: Fields) -> dict[str, str]:
self._validate_allowed_url(uri)
fields.set_field(Field(name="Accept", values=["application/json"]))

attempts = 0
last_exc = None
while attempts < self._retries:
try:
request = HTTPRequest(
method="GET",
destination=uri,
fields=fields,
)
response: HTTPResponse = await self._http_client.send(
request,
request_config=HTTPRequestConfiguration(read_timeout=self._timeout),
)
body = await response.consume_body_async()
if response.status != 200:
raise SmithyIdentityError(
f"Container metadata service returned {response.status}: "
f"{body.decode('utf-8')}"
)
try:
return json.loads(body.decode("utf-8"))
except Exception as error:
raise SmithyIdentityError(
"Unable to parse JSON from container metadata: "
f"{body.decode('utf-8')}"
) from error
except Exception as error:
last_exc = error
await asyncio.sleep(_SLEEP_SECONDS)
attempts += 1

raise SmithyIdentityError(
f"Failed to retrieve container metadata after {self._retries} attempt(s)"
) from last_exc

def _validate_allowed_url(self, uri: URI) -> None:
Comment thread
alexgromero marked this conversation as resolved.
if uri.scheme == "https":
return

if self._is_loopback(uri.host):
return

if not self._is_allowed_container_metadata_host(uri.host):
raise SmithyIdentityError(
f"Unsupported host '{uri.host}'. "
f"Can only retrieve metadata from an HTTPS endpoint, a loopback "
f"address, or one of: {', '.join(_CONTAINER_METADATA_ALLOWED_HOSTS)}"
)

def _is_loopback(self, hostname: str) -> bool:
try:
return ipaddress.ip_address(hostname).is_loopback
except ValueError:
return False

def _is_allowed_container_metadata_host(self, hostname: str) -> bool:
return hostname in _CONTAINER_METADATA_ALLOWED_HOSTS
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
import os

from smithy_aws_core.identity import AWSCredentialsIdentity
from smithy_aws_core.identity.chain import Standard, StandardProvider
from smithy_aws_core.identity.chain.provider import ChainSetup
from smithy_core.interfaces.identity import Identity

from .resolvers import ContainerCredentialsResolver

_RELATIVE_URI = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"
_FULL_URI = "AWS_CONTAINER_CREDENTIALS_FULL_URI"


class EcsContainerProvider:
"""Adds a container credential resolver to the credential chain."""

@property
def name(self) -> str:
"""Return the canonical provider name."""
return StandardProvider.ECS_CONTAINER.canonical_name

@property
def ordering(self) -> Standard:
"""Return the provider's standard chain position."""
return Standard(slot=StandardProvider.ECS_CONTAINER)

async def setup(
self,
identity_type: type[Identity],
setup: ChainSetup,
) -> None:
"""Add a terminal resolver when a container endpoint is configured."""
if identity_type is not AWSCredentialsIdentity:
return
if not os.getenv(_RELATIVE_URI) and not os.getenv(_FULL_URI):
return
setup.add_terminal_resolver(
ContainerCredentialsResolver(http_client=setup.http_client)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Loading