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

Configured endpoint URL resolution #2973

Merged
merged 9 commits into from
Jul 5, 2023
5 changes: 5 additions & 0 deletions .changes/next-release/bugfix-configprovider-46546.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "bugfix",
"category": "configprovider",
"description": "Fix bug when deep copying config value store where overrides were not preserved"
kdaily marked this conversation as resolved.
Show resolved Hide resolved
}
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-configprovider-27540.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "configprovider",
"description": "Always use shallow copy of session config value store for clients"
}
5 changes: 5 additions & 0 deletions .changes/next-release/feature-configuration-3829.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "feature",
"category": "configuration",
"description": "Configure the endpoint URL in the shared configuration file or via an environment variable for a specific AWS service or all AWS services."
}
34 changes: 31 additions & 3 deletions botocore/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def get_client_args(
s3_config = final_args['s3_config']
partition = endpoint_config['metadata'].get('partition', None)
socket_options = final_args['socket_options']

configured_endpoint_url = final_args['configured_endpoint_url']
signing_region = endpoint_config['signing_region']
endpoint_region_name = endpoint_config['region_name']

Expand Down Expand Up @@ -160,7 +160,7 @@ def get_client_args(
service_model,
endpoint_region_name,
region_name,
endpoint_url,
configured_endpoint_url,
endpoint,
is_secure,
endpoint_bridge,
Expand Down Expand Up @@ -210,10 +210,16 @@ def compute_client_args(
parameter_validation = ensure_boolean(raw_value)

s3_config = self.compute_s3_config(client_config)

configured_endpoint_url = self._compute_configured_endpoint_url(
client_config=client_config,
endpoint_url=endpoint_url,
)

endpoint_config = self._compute_endpoint_config(
service_name=service_name,
region_name=region_name,
endpoint_url=endpoint_url,
endpoint_url=configured_endpoint_url,
is_secure=is_secure,
endpoint_bridge=endpoint_bridge,
s3_config=s3_config,
Expand Down Expand Up @@ -270,6 +276,7 @@ def compute_client_args(
return {
'service_name': service_name,
'parameter_validation': parameter_validation,
'configured_endpoint_url': configured_endpoint_url,
'endpoint_config': endpoint_config,
'protocol': protocol,
'config_kwargs': config_kwargs,
Expand All @@ -279,6 +286,27 @@ def compute_client_args(
),
}

def _compute_configured_endpoint_url(self, client_config, endpoint_url):
if endpoint_url is not None:
return endpoint_url

if self._ignore_configured_endpoint_urls(client_config):
logger.debug("Ignoring configured endpoint URLs.")
return endpoint_url

return self._config_store.get_config_variable('endpoint_url')

def _ignore_configured_endpoint_urls(self, client_config):
if (
client_config
and client_config.ignore_configured_endpoint_urls is not None
kdaily marked this conversation as resolved.
Show resolved Hide resolved
):
return client_config.ignore_configured_endpoint_urls

return self._config_store.get_config_variable(
'ignore_configured_endpoint_urls'
)

def compute_s3_config(self, client_config):
s3_configuration = self._config_store.get_config_variable('s3')

Expand Down
8 changes: 8 additions & 0 deletions botocore/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ class Config:

Defaults to None.

:type ignore_configured_endpoint_urls: bool
:param ignore_configured_endpoint_urls: Setting to True disables use
of endpoint URLs provided via environment variables and
the shared configuration file.

Defaults to None.

:type tcp_keepalive: bool
:param tcp_keepalive: Enables the TCP Keep-Alive socket option used when
creating new connections if set to True.
Expand Down Expand Up @@ -221,6 +228,7 @@ class Config:
('endpoint_discovery_enabled', None),
('use_dualstack_endpoint', None),
('use_fips_endpoint', None),
('ignore_configured_endpoint_urls', None),
('defaults_mode', None),
('tcp_keepalive', None),
]
Expand Down
29 changes: 17 additions & 12 deletions botocore/configloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,17 @@ def _parse_nested(config_value):
return parsed


def _parse_section(key, values):
result = {}
try:
parts = shlex.split(key)
except ValueError:
return result
if len(parts) == 2:
result[parts[1]] = values
return result


def build_profile_map(parsed_ini_config):
"""Convert the parsed INI config into a profile map.

Expand Down Expand Up @@ -254,22 +265,15 @@ def build_profile_map(parsed_ini_config):
parsed_config = copy.deepcopy(parsed_ini_config)
profiles = {}
sso_sessions = {}
services = {}
final_config = {}
for key, values in parsed_config.items():
if key.startswith("profile"):
try:
parts = shlex.split(key)
except ValueError:
continue
if len(parts) == 2:
profiles[parts[1]] = values
profiles.update(_parse_section(key, values))
elif key.startswith("sso-session"):
try:
parts = shlex.split(key)
except ValueError:
continue
if len(parts) == 2:
sso_sessions[parts[1]] = values
sso_sessions.update(_parse_section(key, values))
elif key.startswith("services"):
services.update(_parse_section(key, values))
elif key == 'default':
# default section is special and is considered a profile
# name but we don't require you use 'profile "default"'
Expand All @@ -279,4 +283,5 @@ def build_profile_map(parsed_ini_config):
final_config[key] = values
final_config['profiles'] = profiles
final_config['sso_sessions'] = sso_sessions
final_config['services'] = services
return final_config
179 changes: 170 additions & 9 deletions botocore/configprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import os

from botocore import utils
from botocore.exceptions import InvalidConfigError

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -108,6 +109,12 @@
None,
utils.ensure_boolean,
),
'ignore_configured_endpoint_urls': (
'ignore_configured_endpoint_urls',
'AWS_IGNORE_CONFIGURED_ENDPOINT_URLS',
None,
utils.ensure_boolean,
),
'parameter_validation': ('parameter_validation', None, True, None),
# Client side monitoring configurations.
# Note: These configurations are considered internal to botocore.
Expand Down Expand Up @@ -403,7 +410,18 @@ def __init__(self, mapping=None):
self.set_config_provider(logical_name, provider)

def __deepcopy__(self, memo):
return ConfigValueStore(copy.deepcopy(self._mapping, memo))
config_store = ConfigValueStore(copy.deepcopy(self._mapping, memo))
for logical_name, override_value in self._overrides.items():
config_store.set_config_variable(logical_name, override_value)

return config_store

def __copy__(self):
config_store = ConfigValueStore(copy.copy(self._mapping))
for logical_name, override_value in self._overrides.items():
config_store.set_config_variable(logical_name, override_value)

return config_store

def get_config_variable(self, logical_name):
"""
Expand Down Expand Up @@ -543,24 +561,28 @@ def resolve_auto_mode(self, region_name):
return 'standard'

def _update_provider(self, config_store, variable, value):
provider = config_store.get_config_provider(variable)
original_provider = config_store.get_config_provider(variable)
default_provider = ConstantProvider(value)
if isinstance(provider, ChainProvider):
provider.set_default_provider(default_provider)
return
elif isinstance(provider, BaseProvider):
if isinstance(original_provider, ChainProvider):
chain_provider_copy = copy.deepcopy(original_provider)
chain_provider_copy.set_default_provider(default_provider)
default_provider = chain_provider_copy
elif isinstance(original_provider, BaseProvider):
default_provider = ChainProvider(
providers=[provider, default_provider]
providers=[original_provider, default_provider]
)
config_store.set_config_provider(variable, default_provider)

def _update_section_provider(
self, config_store, section_name, variable, value
):
section_provider = config_store.get_config_provider(section_name)
section_provider.set_default_provider(
section_provider_copy = copy.deepcopy(
config_store.get_config_provider(section_name)
)
section_provider_copy.set_default_provider(
variable, ConstantProvider(value)
)
config_store.set_config_provider(section_name, section_provider_copy)

def _set_retryMode(self, config_store, value):
self._update_provider(config_store, 'retry_mode', value)
Expand Down Expand Up @@ -837,3 +859,142 @@ def provide(self):

def __repr__(self):
return 'ConstantProvider(value=%s)' % self._value


class ConfiguredEndpointProvider(BaseProvider):
"""Lookup an endpoint URL from environment variable or shared config file.

NOTE: This class is considered private and is subject to abrupt breaking
changes or removal without prior announcement. Please do not use it
directly.
"""

_ENDPOINT_URL_LOOKUP_ORDER = [
'environment_service',
'environment_global',
'config_service',
'config_global',
]

def __init__(
self,
full_config,
scoped_config,
client_name,
environ=None,
):
"""Initialize a ConfiguredEndpointProviderChain.

:type full_config: dict
:param full_config: This is the dict representing the full
configuration file.

:type scoped_config: dict
:param scoped_config: This is the dict representing the configuration
for the current profile for the session.

:type client_name: str
:param client_name: The name used to instantiate a client using
botocore.session.Session.create_client.

:type environ: dict
:param environ: A mapping to use for environment variables. If this
is not provided it will default to use os.environ.
"""
self._full_config = full_config
self._scoped_config = scoped_config
self._client_name = client_name
self._transformed_service_id = self._get_snake_case_service_id(
self._client_name
)
if environ is None:
environ = os.environ
self._environ = environ

def provide(self):
"""Lookup the configured endpoint URL.

The order is:

1. The value provided by a service-specific environment variable.
2. The value provided by the global endpoint environment variable
(AWS_ENDPOINT_URL).
3. The value provided by a service-specific parameter from a services
definition section in the shared configuration file.
4. The value provided by the global parameter from a services
definition section in the shared configuration file.
"""
for location in self._ENDPOINT_URL_LOOKUP_ORDER:
logger.debug(
'Looking for endpoint for %s via: %s',
self._client_name,
location,
)

endpoint_url = getattr(self, f'_get_endpoint_url_{location}')()

if endpoint_url:
logger.info(
'Found endpoint for %s via: %s.',
self._client_name,
location,
)
return endpoint_url

logger.debug('No configured endpoint found.')
return None

def _get_snake_case_service_id(self, client_name):
# Get the service ID without loading the service data file, accounting
# for any aliases and standardizing the names with hyphens.
client_name = utils.SERVICE_NAME_ALIASES.get(client_name, client_name)
hyphenized_service_id = (
utils.CLIENT_NAME_TO_HYPHENIZED_SERVICE_ID_OVERRIDES.get(
client_name, client_name
)
)
return hyphenized_service_id.replace('-', '_')

def _get_service_env_var_name(self):
transformed_service_id_env = self._transformed_service_id.upper()
return f'AWS_ENDPOINT_URL_{transformed_service_id_env}'

def _get_services_config(self):
if 'services' not in self._scoped_config:
return {}

section_name = self._scoped_config['services']
services_section = self._full_config.get('services', {}).get(
section_name
)

if not services_section:
error_msg = (
f'The profile is configured to use the services '
f'section but the "{section_name}" services '
f'configuration does not exist.'
)
raise InvalidConfigError(error_msg=error_msg)

return services_section

def _get_endpoint_url_config_service(self):
snakecase_service_id = self._transformed_service_id.lower()
return (
self._get_services_config()
.get(snakecase_service_id, {})
.get('endpoint_url')
)

def _get_endpoint_url_config_global(self):
return self._scoped_config.get('endpoint_url')

def _get_endpoint_url_environment_service(self):
return EnvironmentProvider(
name=self._get_service_env_var_name(), env=self._environ
).provide()

def _get_endpoint_url_environment_global(self):
return EnvironmentProvider(
name='AWS_ENDPOINT_URL', env=self._environ
).provide()
Loading