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
39 changes: 39 additions & 0 deletions .github/workflows/check-license.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: License Header Check

on:
pull_request:
paths:
- "**/*.py"
- "!clients/**"
- ".github/workflows/check-license.yml"
- ".licenserc.yaml"
push:
branches:
- develop
paths:
- "**/*.py"
- "!clients/**"
- ".github/workflows/check-license.yml"
- ".licenserc.yaml"

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
check-license-header:
name: Check License Header
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false

- name: Check license headers
uses: apache/skywalking-eyes/header@eddd8f193e5c1739a76dad4074f50ede635a19fe
with:
mode: check
67 changes: 67 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: CI

on:
pull_request:
paths:
- ".github/workflows/ci.yml"
- "Makefile"
- "packages/**"
- "pyproject.toml"
- "uv.lock"
push:
branches:
- develop
- main
paths:
- ".github/workflows/ci.yml"
- "Makefile"
- "packages/**"
- "pyproject.toml"
- "uv.lock"

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
ci:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
continue-on-error: ${{ matrix.python-version == '3.15' }}
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14", "3.14t", "3.15"]

steps:
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false

- name: Install uv and set the Python version
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9
with:
python-version: ${{ matrix.python-version }}
activate-environment: true
enable-cache: true

- name: Setup workspace
run: |
make install

- name: Check python packages
if: ${{ matrix.python-version == '3.12' }}
run: |
make check-py

- name: Test python packages
run: |
make test-py

- name: Build python packages
run: |
make build-py
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ venv

# Caches
__pycache__/
.coverage
htmlcov/
.ruff_cache/
.pytest_cache/

Expand Down
18 changes: 18 additions & 0 deletions .licenserc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
header:
license:
spdx-id: Apache-2.0
copyright-owner: Amazon.com, Inc. or its affiliates.
content: |
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0

paths:
- "**/*.py"

paths-ignore:
- "clients/**"

language:
Python:
extensions:
- ".py"
21 changes: 20 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
DOCS_PORT ?= 8000
PYTHON_VERSION := 3.12

.PHONY: docs docs-serve docs-clean docs-install docs-lock venv
.PHONY: build-py check-py docs docs-serve docs-clean docs-install docs-lock \
install lint-py test-py venv

install:
uv sync --all-packages --all-extras

lint-py:
uv run ruff check packages --fix --config pyproject.toml
uv run ruff format packages --config pyproject.toml

check-py:
uv run ruff check packages --config pyproject.toml
uv run ruff format --check packages --config pyproject.toml
uv run pyright packages

test-py:
uv run pytest packages

build-py:
uv build --all-packages

venv:
uv venv --python $(PYTHON_VERSION)
Expand Down
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.
5 changes: 5 additions & 0 deletions packages/aws-credentials-http/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# aws-credentials-http

This package provides a container HTTP credential resolver and chain provider.
Installing it automatically adds the `ECS_CONTAINER` source to the SDK's modular
AWS credential chain.
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"]
11 changes: 11 additions & 0 deletions packages/aws-credentials-http/src/aws_credentials_http/__init__.py
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
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:
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
Loading