Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add generalized ECS credential provider support #2837

Merged
merged 5 commits into from
Mar 21, 2023
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
2 changes: 2 additions & 0 deletions gems/aws-sdk-core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
Unreleased Changes
------------------

* Feature - Add support for `AWS_CONTAINER_CREDENTIALS_FULL_URI` and `AWS_CONTAINER_AUTHORIZATION_TOKEN` environment variables to `ECSCredentials`.

3.170.1 (2023-03-17)
------------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ def assume_role_web_identity_credentials(options)

def instance_profile_credentials(options)
profile_name = determine_profile_name(options)
if ENV['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI']
if ENV['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'] ||
ENV['AWS_CONTAINER_CREDENTIALS_FULL_URI']
ECSCredentials.new(options)
else
InstanceProfileCredentials.new(options.merge(profile: profile_name))
Expand Down
164 changes: 111 additions & 53 deletions gems/aws-sdk-core/lib/aws-sdk-core/ecs_credentials.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require 'time'
require 'net/http'
require 'resolv'

module Aws
# An auto-refreshing credential provider that loads credentials from
Expand All @@ -10,7 +11,6 @@ module Aws
# ecs_credentials = Aws::ECSCredentials.new(retries: 3)
# ec2 = Aws::EC2::Client.new(credentials: ecs_credentials)
class ECSCredentials

include CredentialProvider
include RefreshingCredentials

Expand All @@ -29,16 +29,22 @@ class Non200Response < RuntimeError; end
Errno::ENETUNREACH,
SocketError,
Timeout::Error,
Non200Response,
]
Non200Response
].freeze

# @param [Hash] options
# @option options [Integer] :retries (5) Number of times to retry
# when retrieving credentials.
# @option options [String] :ip_address ('169.254.170.2')
# @option options [Integer] :port (80)
# @option options [String] :ip_address ('169.254.170.2') This value is
# ignored if `endpoint` is set and `credential_path` is not set.
# @option options [Integer] :port (80) This value is ignored if `endpoint`
# is set and `credential_path` is not set.
# @option options [String] :credential_path By default, the value of the
# AWS_CONTAINER_CREDENTIALS_RELATIVE_URI environment variable.
# @option options [String] :endpoint The ECS credential endpoint.
# By default, this is the value of the AWS_CONTAINER_CREDENTIALS_FULL_URI
# environment variable. This value is ignored if `credential_path` or
# ENV['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'] is set.
mullermp marked this conversation as resolved.
Show resolved Hide resolved
# @option options [Float] :http_open_timeout (5)
# @option options [Float] :http_read_timeout (5)
# @option options [Numeric, Proc] :delay By default, failures are retried
Expand All @@ -52,17 +58,15 @@ class Non200Response < RuntimeError; end
# credentials are refreshed. `before_refresh` is called
# with an instance of this object when
# AWS credentials are required and need to be refreshed.
def initialize options = {}
def initialize(options = {})
credential_path = options[:credential_path] ||
ENV['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI']
endpoint = options[:endpoint] ||
ENV['AWS_CONTAINER_CREDENTIALS_FULL_URI']
initialize_uri(options, credential_path, endpoint)
@authorization_token = ENV['AWS_CONTAINER_AUTHORIZATION_TOKEN']

@retries = options[:retries] || 5
@ip_address = options[:ip_address] || '169.254.170.2'
@port = options[:port] || 80
@credential_path = options[:credential_path]
@credential_path ||= ENV['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI']
unless @credential_path
raise ArgumentError.new(
"Cannot instantiate an ECS Credential Provider without a credential path."
)
end
@http_open_timeout = options[:http_open_timeout] || 5
@http_read_timeout = options[:http_read_timeout] || 5
@http_debug_output = options[:http_debug_output]
Expand All @@ -77,80 +81,134 @@ def initialize options = {}

private

def initialize_uri(options, credential_path, endpoint)
if credential_path
initialize_relative_uri(options, credential_path)
# Use FULL_URI/endpoint only if RELATIVE_URI/path is not set
elsif endpoint
initialize_full_uri(endpoint)
else
raise ArgumentError,
'Cannot instantiate an ECS Credential Provider '\
'without a credential path or endpoint.'
end
end

def initialize_relative_uri(options, path)
@host = options[:ip_address] || '169.254.170.2'
@port = options[:port] || 80
@scheme = 'http'
@credential_path = path
end

def initialize_full_uri(endpoint)
uri = URI.parse(endpoint)
validate_full_uri!(uri)
@host = uri.host
@port = uri.port
@scheme = uri.scheme
@credential_path = uri.path
end

# Validate that the full URI is using a loopback address if scheme is http.
def validate_full_uri!(full_uri)
return unless full_uri.scheme == 'http'

begin
return if ip_loopback?(IPAddr.new(full_uri.host))
rescue IPAddr::InvalidAddressError
addresses = Resolv.getaddresses(full_uri.host)
return if addresses.all? { |addr| ip_loopback?(IPAddr.new(addr)) }
end

raise ArgumentError,
'AWS_CONTAINER_CREDENTIALS_FULL_URI must use a loopback '\
'address when using the http scheme.'
end

# loopback? method is available in Ruby 2.5+
# Replicate the logic here.
def ip_loopback?(ip_address)
case ip_address.family
when Socket::AF_INET
ip_address & 0xff000000 == 0x7f000000
when Socket::AF_INET6
ip_address == 1
else
false
end
end

def backoff(backoff)
mullermp marked this conversation as resolved.
Show resolved Hide resolved
case backoff
when Proc then backoff
when Numeric then lambda { |_| sleep(backoff) }
else lambda { |num_failures| Kernel.sleep(1.2 ** num_failures) }
when Numeric then ->(_) { sleep(backoff) }
else ->(num_failures) { Kernel.sleep(1.2**num_failures) }
end
end

def refresh
# Retry loading credentials up to 3 times is the instance metadata
# service is responding but is returning invalid JSON documents
# in response to the GET profile credentials call.
begin
retry_errors([Aws::Json::ParseError, StandardError], max_retries: 3) do
c = Aws::Json.load(get_credentials.to_s)
@credentials = Credentials.new(
c['AccessKeyId'],
c['SecretAccessKey'],
c['Token']
)
@expiration = c['Expiration'] ? Time.iso8601(c['Expiration']) : nil
end
rescue Aws::Json::ParseError
raise Aws::Errors::MetadataParserError.new

retry_errors([Aws::Json::ParseError, StandardError], max_retries: 3) do
c = Aws::Json.load(get_credentials.to_s)
@credentials = Credentials.new(
c['AccessKeyId'],
c['SecretAccessKey'],
c['Token']
)
@expiration = c['Expiration'] ? Time.iso8601(c['Expiration']) : nil
end
rescue Aws::Json::ParseError
raise Aws::Errors::MetadataParserError
end

def get_credentials
# Retry loading credentials a configurable number of times if
# the instance metadata service is not responding.
begin
retry_errors(NETWORK_ERRORS, max_retries: @retries) do
open_connection do |conn|
http_get(conn, @credential_path)
end

retry_errors(NETWORK_ERRORS, max_retries: @retries) do
open_connection do |conn|
http_get(conn, @credential_path)
end
rescue
'{}'
end
rescue StandardError
'{}'
end

def open_connection
http = Net::HTTP.new(@ip_address, @port, nil)
http = Net::HTTP.new(@host, @port, nil)
http.open_timeout = @http_open_timeout
http.read_timeout = @http_read_timeout
http.set_debug_output(@http_debug_output) if @http_debug_output
http.use_ssl = @scheme == 'https'
http.start
yield(http).tap { http.finish }
end

def http_get(connection, path)
response = connection.request(Net::HTTP::Get.new(path))
if response.code.to_i == 200
response.body
else
raise Non200Response
end
request = Net::HTTP::Get.new(path)
request['Authorization'] = @authorization_token if @authorization_token
response = connection.request(request)
raise Non200Response unless response.code.to_i == 200

response.body
end

def retry_errors(error_classes, options = {}, &block)
def retry_errors(error_classes, options = {})
max_retries = options[:max_retries]
retries = 0
begin
yield
rescue *error_classes => _error
if retries < max_retries
@backoff.call(retries)
retries += 1
retry
else
raise
end
rescue *error_classes => _e
raise unless retries < max_retries

@backoff.call(retries)
retries += 1
retry
end
end

end
end
8 changes: 8 additions & 0 deletions gems/aws-sdk-core/spec/aws/credential_provider_chain_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ def validate_credentials(expected_creds)
expect(credentials).to be(mock_ecs_creds)
end

it 'hydrates credentials from ECS when AWS_CONTAINER_CREDENTIALS_FULL_URI is set' do
ENV['AWS_CONTAINER_CREDENTIALS_FULL_URI'] = 'test_uri'
mock_ecs_creds = double('ECSCredentials')
expect(ECSCredentials).to receive(:new).and_return(mock_ecs_creds)
expect(mock_ecs_creds).to receive(:set?).and_return(true)
expect(credentials).to be(mock_ecs_creds)
end

describe 'with config set to nil' do
let(:config) { nil }

Expand Down
Loading