From ace597dbcb2c7309c515c56490b555cd2e2d9a5f Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Tue, 1 Nov 2022 15:40:00 -0400 Subject: [PATCH 01/10] Add export-creds command to the CLI This PR builds on the interface proposed in #6808 and implements the additional features proposed in #7388. From the original PRs, the additional features are: * Added support for an explicit `--format` args to control the output format. * Add support for env vars, powershell/windows vars, and a JSON format that's enables this command to be used as a `credential_process`. * Detect, and prevent infinite recursion when the credential process resolution results in the CLI calling itself with the same command. Closes #7388 Closes #5261 --- awscli/customizations/configure/configure.py | 3 + .../customizations/configure/exportcreds.py | 183 +++++++++++++++ .../configure/test_exportcreds.py | 210 ++++++++++++++++++ 3 files changed, 396 insertions(+) create mode 100644 awscli/customizations/configure/exportcreds.py create mode 100644 tests/unit/customizations/configure/test_exportcreds.py diff --git a/awscli/customizations/configure/configure.py b/awscli/customizations/configure/configure.py index b2031bb7c168..efe625247065 100644 --- a/awscli/customizations/configure/configure.py +++ b/awscli/customizations/configure/configure.py @@ -25,6 +25,8 @@ from awscli.customizations.configure.importer import ConfigureImportCommand from awscli.customizations.configure.listprofiles import ListProfilesCommand from awscli.customizations.configure.sso import ConfigureSSOCommand +from awscli.customizations.configure.exportcreds import \ + ConfigureExportCredsCommand from . import mask_value, profile_to_section @@ -80,6 +82,7 @@ class ConfigureCommand(BasicCommand): {'name': 'import', 'command_class': ConfigureImportCommand}, {'name': 'list-profiles', 'command_class': ListProfilesCommand}, {'name': 'sso', 'command_class': ConfigureSSOCommand}, + {'name': 'export-creds', 'command_class': ConfigureExportCredsCommand}, ] # If you want to add new values to prompt, update this list here. diff --git a/awscli/customizations/configure/exportcreds.py b/awscli/customizations/configure/exportcreds.py new file mode 100644 index 000000000000..ed0e8da86023 --- /dev/null +++ b/awscli/customizations/configure/exportcreds.py @@ -0,0 +1,183 @@ +# Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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. +import os +import sys +import json +from datetime import datetime +from collections import namedtuple + +from awscli.customizations.commands import BasicCommand + + +# Takes botocore's ReadOnlyCredentials and exposes an expiry_time. +Credentials = namedtuple( + 'Credentials', ['access_key', 'secret_key', 'token', 'expiry_time']) + + +def convert_botocore_credentials(credentials): + # Converts botocore credentials to our `Credentials` type. + frozen = credentials.get_frozen_credentials() + expiry_time_str = None + # Botocore does not expose an attribute for the expiry_time of temporary + # credentials, so for the time being we need to access an internal + # attribute to retrieve this info. We're following up to see if botocore + # can make this a public attribute. + expiry_time = getattr(credentials, '_expiry_time', None) + if expiry_time is not None and isinstance(expiry_time, datetime): + expiry_time_str = expiry_time.isoformat() + return Credentials( + access_key=frozen.access_key, + secret_key=frozen.secret_key, + token=frozen.token, + expiry_time=expiry_time_str, + ) + + +class BaseCredentialFormatter(object): + + FORMAT = None + + def __init__(self, stream=None): + if stream is None: + stream = sys.stdout + self._stream = stream + + def display_credentials(self, credentials): + pass + + +class BashEnvVarFormatter(BaseCredentialFormatter): + + FORMAT = 'env' + + def display_credentials(self, credentials): + output = ( + f'export AWS_ACCESS_KEY_ID={credentials.access_key}\n' + f'export AWS_SECRET_ACCESS_KEY={credentials.secret_key}\n' + ) + if credentials.token is not None: + output += f'export AWS_SESSION_TOKEN={credentials.token}\n' + self._stream.write(output) + + +class PowershellFormatter(BaseCredentialFormatter): + + FORMAT = 'powershell' + + def display_credentials(self, credentials): + output = ( + f'$Env:AWS_ACCESS_KEY_ID="{credentials.access_key}"\n' + f'$Env:AWS_SECRET_ACCESS_KEY="{credentials.secret_key}"\n' + ) + if credentials.token is not None: + output += f'$Env:AWS_SESSION_TOKEN="{credentials.token}"\n' + self._stream.write(output) + + +class WindowsCmdFormatter(BaseCredentialFormatter): + + FORMAT = 'windows-cmd' + + def display_credentials(self, credentials): + output = ( + f'set AWS_ACCESS_KEY_ID={credentials.access_key}\n' + f'set AWS_SECRET_ACCESS_KEY={credentials.secret_key}\n' + ) + if credentials.token is not None: + output += f'set AWS_SESSION_TOKEN={credentials.token}\n' + self._stream.write(output) + + +class CredentialProcessFormatter(BaseCredentialFormatter): + + FORMAT = 'process' + + def display_credentials(self, credentials): + output = { + 'Version': 1, + 'AccessKeyId': credentials.access_key, + 'SecretAccessKey': credentials.secret_key, + } + if credentials.token is not None: + output['SessionToken'] = credentials.token + if credentials.expiry_time is not None: + output['Expiration'] = credentials.expiry_time + self._stream.write( + json.dumps(output, indent=2, separators=(',', ': ')) + ) + self._stream.write('\n') + + +SUPPORTED_FORMATS = { + format_cls.FORMAT: format_cls for format_cls in + [BashEnvVarFormatter, CredentialProcessFormatter, PowershellFormatter, + WindowsCmdFormatter] +} + + +class ConfigureExportCredsCommand(BasicCommand): + NAME = 'export-creds' + SYNOPSIS = 'aws configure export-creds --profile profile-name' + ARG_TABLE = [ + {'name': 'format', + 'help_text': ( + 'The output format to display credentials.' + 'Defaults to "process".'), + 'action': 'store', + 'choices': list(SUPPORTED_FORMATS), + 'default': CredentialProcessFormatter.FORMAT}, + ] + _RECURSION_VAR = '_AWS_CLI_RESOLVING_CREDS' + + def __init__(self, session, out_stream=None, error_stream=None, env=None): + super(ConfigureExportCredsCommand, self).__init__(session) + if out_stream is None: + out_stream = sys.stdout + if error_stream is None: + error_stream = sys.stderr + if env is None: + env = os.environ + self._out_stream = out_stream + self._error_stream = error_stream + self._env = env + + def _recursion_detected(self): + return self._RECURSION_VAR in self._env + + def _set_recursion_barrier(self): + self._env[self._RECURSION_VAR] = 'true' + + def _run_main(self, parsed_args, parsed_globals): + if self._recursion_detected(): + self._error_stream.write( + "\n\nRecursive credential resolution process detected.\n" + "Try setting an explicit '--profile' value in the " + "'credential_process' configuration:\n\n" + "credential_process = aws configure export-creds " + "--profile other-profile\n" + ) + return 2 + self._set_recursion_barrier() + try: + creds = self._session.get_credentials() + except Exception as e: + self._error_stream.write( + "Unable to retrieve credentials: %s\n" % e) + return 1 + if creds is None: + self._error_stream.write( + "Unable to retrieve credentials: no credentials found\n") + return 1 + creds_with_expiry = convert_botocore_credentials(creds) + formatter = SUPPORTED_FORMATS[parsed_args.format](self._out_stream) + formatter.display_credentials(creds_with_expiry) diff --git a/tests/unit/customizations/configure/test_exportcreds.py b/tests/unit/customizations/configure/test_exportcreds.py new file mode 100644 index 000000000000..1ae5f116f518 --- /dev/null +++ b/tests/unit/customizations/configure/test_exportcreds.py @@ -0,0 +1,210 @@ +# Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file 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. +import json +import mock +import io +from datetime import datetime, timedelta + +from dateutil.tz import tzutc +import pytest +from botocore.credentials import RefreshableCredentials +from botocore.credentials import Credentials as StaticCredentials +from botocore.credentials import ReadOnlyCredentials +from botocore.session import Session + +from awscli.testutils import unittest +from awscli.customizations.configure.exportcreds import ( + Credentials, + convert_botocore_credentials, + ConfigureExportCredsCommand, + BashEnvVarFormatter, + PowershellFormatter, + WindowsCmdFormatter, + CredentialProcessFormatter, +) + + +class JSONValue: + def __init__(self, json_str): + self.json_str = json_str + self.parsed_json = json.loads(json_str) + + def __eq__(self, other): + if isinstance(other, str): + other_parsed = json.loads(other) + return self.parsed_json == other_parsed + return self.parsed_json == other + + +@pytest.mark.parametrize( + 'format_cls, expected', [ + (BashEnvVarFormatter, ( + ('export AWS_ACCESS_KEY_ID=access_key\n' + 'export AWS_SECRET_ACCESS_KEY=secret_key\n'), + ('export AWS_ACCESS_KEY_ID=access_key\n' + 'export AWS_SECRET_ACCESS_KEY=secret_key\n' + 'export AWS_SESSION_TOKEN=token\n'), + )), + (PowershellFormatter, ( + ('$Env:AWS_ACCESS_KEY_ID="access_key"\n' + '$Env:AWS_SECRET_ACCESS_KEY="secret_key"\n'), + ('$Env:AWS_ACCESS_KEY_ID="access_key"\n' + '$Env:AWS_SECRET_ACCESS_KEY="secret_key"\n' + '$Env:AWS_SESSION_TOKEN="token"\n'), + )), + (WindowsCmdFormatter, ( + ('set AWS_ACCESS_KEY_ID=access_key\n' + 'set AWS_SECRET_ACCESS_KEY=secret_key\n'), + ('set AWS_ACCESS_KEY_ID=access_key\n' + 'set AWS_SECRET_ACCESS_KEY=secret_key\n' + 'set AWS_SESSION_TOKEN=token\n'), + )), + (CredentialProcessFormatter, ( + JSONValue( + '{"Version": 1, "AccessKeyId": "access_key", ' + '"SecretAccessKey": "secret_key"}'), + JSONValue( + '{"Version": 1, "AccessKeyId": "access_key", ' + '"SecretAccessKey": "secret_key", "SessionToken": ' + '"token", "Expiration": "2023-01-01T00:00:00Z"}'), + )), + ] +) +def test_cred_formatter(format_cls, expected): + stream = io.StringIO() + expected_static, expected_temporary = expected + formatter = format_cls(stream) + + # Static case, only access_key/secret_key + creds = Credentials('access_key', 'secret_key', None, None) + formatter.display_credentials(creds) + assert stream.getvalue() == expected_static + + # Refreshable case, access_key/secret_key/token/expiration. + # Reset back to an empty stream. + stream.truncate(0) + stream.seek(0) + expiry = '2023-01-01T00:00:00Z' + temp_creds = Credentials( + 'access_key', 'secret_key', 'token', expiry) + formatter.display_credentials(temp_creds) + assert stream.getvalue() == expected_temporary + + +class TestCanConvertBotocoreCredentials(unittest.TestCase): + def test_can_convert_static_creds_with_no_expiry(self): + self.assertEqual( + convert_botocore_credentials( + StaticCredentials('access_key', 'secret_key') + ), + Credentials('access_key', 'secret_key', token=None, + expiry_time=None) + ) + + def test_can_convert_creds_with_token_and_no_expiry(self): + self.assertEqual( + convert_botocore_credentials( + StaticCredentials('access_key', 'secret_key', 'token') + ), + Credentials('access_key', 'secret_key', 'token', expiry_time=None) + ) + + def test_can_convert_refreshable_with_expiry(self): + expiry = datetime.now(tzutc()) + timedelta(hours=12) + self.assertEqual( + convert_botocore_credentials( + RefreshableCredentials( + 'access_key', + 'secret_key', + 'token', + expiry_time=expiry, + refresh_using=None, + method='explicit', + ) + ), + Credentials('access_key', 'secret_key', 'token', + expiry_time=expiry.isoformat()) + ) + + def test_no_expiry_time_if_non_datetime_value(self): + bad_creds = mock.Mock(spec=StaticCredentials) + bad_creds.get_frozen_credentials.return_value = ReadOnlyCredentials( + 'access_key', 'secret_key', 'token') + bad_creds._expiry_time = 'not a datetime' + self.assertEqual( + convert_botocore_credentials(bad_creds), + Credentials('access_key', 'secret_key', 'token', + expiry_time=None) + ) + + +class TestConfigureExportCredsCommand(unittest.TestCase): + def setUp(self): + self.session = mock.Mock(spec=Session) + self.session.emit_first_non_none_response.return_value = None + self.out_stream = io.StringIO() + self.err_stream = io.StringIO() + self.os_env = {} + self.export_creds_cmd = ConfigureExportCredsCommand( + self.session, self.out_stream, self.err_stream, env=self.os_env) + self.global_args = mock.Mock() + self.expiry = '2023-01-01T00:00:00Z' + self.creds = StaticCredentials('access_key', 'secret_key') + + def test_can_export_creds(self): + self.session.get_credentials.return_value = self.creds + rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + self.assertEqual(rc, 0) + self.assertEqual( + self.out_stream.getvalue(), + JSONValue( + '{"Version": 1, "AccessKeyId": "access_key", ' + '"SecretAccessKey": "secret_key"}') + ) + + def test_can_export_creds_explicit_format(self): + self.session.get_credentials.return_value = self.creds + rc = self.export_creds_cmd( + args=['--format', 'env'], + parsed_globals=self.global_args) + self.assertEqual(rc, 0) + self.assertEqual( + self.out_stream.getvalue(), + 'export AWS_ACCESS_KEY_ID=access_key\n' + 'export AWS_SECRET_ACCESS_KEY=secret_key\n' + ) + + def test_show_error_when_no_cred(self): + self.session.get_credentials.return_value = None + rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + self.assertIn( + 'Unable to retrieve credentials', self.err_stream.getvalue() + ) + self.assertEqual(rc, 1) + + def test_show_error_when_cred_resolution_errors(self): + self.session.get_credentials.side_effect = Exception("resolution failed") + rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + self.assertIn( + 'resolution failed', self.err_stream.getvalue() + ) + self.assertEqual(rc, 1) + + def test_can_detect_recursive_resolution(self): + self.os_env['_AWS_CLI_RESOLVING_CREDS'] = 'true' + rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + self.assertIn( + 'Recursive credential resolution process detected', + self.err_stream.getvalue() + ) + self.assertEqual(rc, 2) From 37bcba32a6b31783f93654b8cc241c5df26374a8 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Wed, 9 Nov 2022 12:45:03 -0500 Subject: [PATCH 02/10] Add more lenient recursion detection There's a reasonable scenario where at least two levels of recursion is reasonable. Specifically if a user explicitly runs the `aws configure export-creds` command with a profile that uses a `credential_process` with a value of `aws configure export-creds --profile other-profile`. To handle this I've modified the env var to now track the stack of profiles seen and ensure there's no cycles given the current profile. I also added a final safeguard of a general recursion limit of 4. I figured that 2 levels has valid real world scenarios, we'll give one more level as a buffer, so fail at 4. I don't feel super strongly about the hard limit, my rationale being if a user wants to configure some long `export-creds` chain of 10, it's their configuration so let them, but I can't come up with a valid scenario where yuo'd want more than 4. However, I'd be cool with removing the hard limit if we don't think it's necessary. --- .../customizations/configure/exportcreds.py | 48 ++++++++++-- .../configure/test_exportcreds.py | 78 ++++++++++++++++++- 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/awscli/customizations/configure/exportcreds.py b/awscli/customizations/configure/exportcreds.py index ed0e8da86023..7269d20ee0bc 100644 --- a/awscli/customizations/configure/exportcreds.py +++ b/awscli/customizations/configure/exportcreds.py @@ -11,7 +11,9 @@ # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. import os +import io import sys +import csv import json from datetime import datetime from collections import namedtuple @@ -137,7 +139,13 @@ class ConfigureExportCredsCommand(BasicCommand): 'choices': list(SUPPORTED_FORMATS), 'default': CredentialProcessFormatter.FORMAT}, ] - _RECURSION_VAR = '_AWS_CLI_RESOLVING_CREDS' + _RECURSION_VAR = '_AWS_CLI_PROFILE_CHAIN' + # Two levels is reasonable because you might explicitly run + # "aws configure export-creds" with a profile that is configured + # with a credential_process of "aws configure export-creds". + # So we'll give one more level of recursion for padding and then + # error out when we hit _MAX_RECURSION. + _MAX_RECURSION = 4 def __init__(self, session, out_stream=None, error_stream=None, env=None): super(ConfigureExportCredsCommand, self).__init__(session) @@ -151,18 +159,46 @@ def __init__(self, session, out_stream=None, error_stream=None, env=None): self._error_stream = error_stream self._env = env - def _recursion_detected(self): - return self._RECURSION_VAR in self._env + def _recursion_barrier_detected(self): + profile = self._get_current_profile() + seen_profiles = self._parse_profile_chain( + self._env.get(self._RECURSION_VAR, '')) + if len(seen_profiles) >= self._MAX_RECURSION: + return True + return profile in seen_profiles def _set_recursion_barrier(self): - self._env[self._RECURSION_VAR] = 'true' + profile = self._get_current_profile() + seen_profiles = self._parse_profile_chain( + self._env.get(self._RECURSION_VAR, '')) + seen_profiles.append(profile) + serialized = self._serialize_to_csv_str(seen_profiles) + self._env[self._RECURSION_VAR] = serialized + + def _serialize_to_csv_str(self, profiles): + out = io.StringIO() + w = csv.writer(out) + w.writerow(profiles) + serialized = out.getvalue().strip() + return serialized + + def _get_current_profile(self): + profile = self._session.get_config_variable('profile') + if profile is None: + profile = 'default' + return profile + + def _parse_profile_chain(self, value): + result = list(csv.reader([value]))[0] + return result def _run_main(self, parsed_args, parsed_globals): - if self._recursion_detected(): + if self._recursion_barrier_detected(): self._error_stream.write( "\n\nRecursive credential resolution process detected.\n" "Try setting an explicit '--profile' value in the " - "'credential_process' configuration:\n\n" + "'credential_process' configuration and ensure there " + "are no cycles:\n\n" "credential_process = aws configure export-creds " "--profile other-profile\n" ) diff --git a/tests/unit/customizations/configure/test_exportcreds.py b/tests/unit/customizations/configure/test_exportcreds.py index 1ae5f116f518..7980cc36cc12 100644 --- a/tests/unit/customizations/configure/test_exportcreds.py +++ b/tests/unit/customizations/configure/test_exportcreds.py @@ -155,6 +155,7 @@ def setUp(self): self.out_stream = io.StringIO() self.err_stream = io.StringIO() self.os_env = {} + self.session.get_config_variable.return_value = 'default' self.export_creds_cmd = ConfigureExportCredsCommand( self.session, self.out_stream, self.err_stream, env=self.os_env) self.global_args = mock.Mock() @@ -193,7 +194,8 @@ def test_show_error_when_no_cred(self): self.assertEqual(rc, 1) def test_show_error_when_cred_resolution_errors(self): - self.session.get_credentials.side_effect = Exception("resolution failed") + self.session.get_credentials.side_effect = Exception( + "resolution failed") rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) self.assertIn( 'resolution failed', self.err_stream.getvalue() @@ -201,7 +203,79 @@ def test_show_error_when_cred_resolution_errors(self): self.assertEqual(rc, 1) def test_can_detect_recursive_resolution(self): - self.os_env['_AWS_CLI_RESOLVING_CREDS'] = 'true' + self.os_env['_AWS_CLI_PROFILE_CHAIN'] = 'default' + rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + self.assertIn( + 'Recursive credential resolution process detected', + self.err_stream.getvalue() + ) + self.assertEqual(rc, 2) + + def test_nested_calls_not_recursive(self): + self.session.get_credentials.return_value = self.creds + # If we've seen the profiles foo and bar: + self.os_env['_AWS_CLI_PROFILE_CHAIN'] = 'foo,bar' + # And we're now on baz, then we shouldn't get an error + # because there's no cycle. + self.session.get_config_variable.return_value = 'baz' + rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + self.assertEqual(rc, 0) + self.assertEqual( + self.out_stream.getvalue(), + JSONValue( + '{"Version": 1, "AccessKeyId": "access_key", ' + '"SecretAccessKey": "secret_key"}') + ) + + def test_nested_calls_with_cycle(self): + self.session.get_credentials.return_value = self.creds + self.os_env['_AWS_CLI_PROFILE_CHAIN'] = 'foo,bar,baz' + self.session.get_config_variable.return_value = 'bar' + rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + self.assertIn( + 'Recursive credential resolution process detected', + self.err_stream.getvalue() + ) + self.assertEqual(rc, 2) + + def test_handles_comma_char_in_profile_name_no_cycle(self): + self.session.get_credentials.return_value = self.creds + self.os_env['_AWS_CLI_PROFILE_CHAIN'] = 'foo,bar,baz' + # Shouldn't be a cycle, the comma is part of the profile name. + self.session.get_config_variable.return_value = 'bar,baz' + rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + self.assertEqual(rc, 0) + self.assertEqual( + self.out_stream.getvalue(), + JSONValue( + '{"Version": 1, "AccessKeyId": "access_key", ' + '"SecretAccessKey": "secret_key"}') + ) + + def test_detects_comma_char_with_cycle(self): + self.session.get_credentials.return_value = self.creds + # To test the round tripping of CSV writing/reading, we'll invoke + # the command twice to ensure however it records the profiles + # that it detects a cycle with names that need to be escaped/quoted. + self.os_env['_AWS_CLI_PROFILE_CHAIN'] = 'foo,bar' + self.session.get_config_variable.return_value = 'bar,baz' + # First time it succeeds. + rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + self.assertEqual(rc, 0) + # Second time, it detects the cycle. + second_invoke = ConfigureExportCredsCommand( + self.session, self.out_stream, self.err_stream, env=self.os_env) + rc = second_invoke(args=[], parsed_globals=self.global_args) + self.assertIn( + 'Recursive credential resolution process detected', + self.err_stream.getvalue() + ) + self.assertEqual(rc, 2) + + def test_max_recursion_limit(self): + self.session.get_credentials.return_value = self.creds + self.os_env['_AWS_CLI_PROFILE_CHAIN'] = ','.join( + ['a', 'b', 'c', 'd', 'e', 'f', 'g']) rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) self.assertIn( 'Recursive credential resolution process detected', From 949e46640d3920160fc309a907b697ba0dd3b793 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Tue, 15 Nov 2022 13:42:17 -0500 Subject: [PATCH 03/10] Add AWS_CREDENTIAL_EXPIRATION to exported env vars --- awscli/customizations/configure/exportcreds.py | 12 ++++++++++++ .../customizations/configure/test_exportcreds.py | 9 ++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/awscli/customizations/configure/exportcreds.py b/awscli/customizations/configure/exportcreds.py index 7269d20ee0bc..9f17ba458327 100644 --- a/awscli/customizations/configure/exportcreds.py +++ b/awscli/customizations/configure/exportcreds.py @@ -69,6 +69,10 @@ def display_credentials(self, credentials): ) if credentials.token is not None: output += f'export AWS_SESSION_TOKEN={credentials.token}\n' + if credentials.expiry_time is not None: + output += ( + f'export AWS_CREDENTIAL_EXPIRATION={credentials.expiry_time}\n' + ) self._stream.write(output) @@ -83,6 +87,10 @@ def display_credentials(self, credentials): ) if credentials.token is not None: output += f'$Env:AWS_SESSION_TOKEN="{credentials.token}"\n' + if credentials.expiry_time is not None: + output += ( + f'$Env:AWS_CREDENTIAL_EXPIRATION={credentials.expiry_time}\n' + ) self._stream.write(output) @@ -97,6 +105,10 @@ def display_credentials(self, credentials): ) if credentials.token is not None: output += f'set AWS_SESSION_TOKEN={credentials.token}\n' + if credentials.expiry_time is not None: + output += ( + f'set AWS_CREDENTIAL_EXPIRATION={credentials.expiry_time}\n' + ) self._stream.write(output) diff --git a/tests/unit/customizations/configure/test_exportcreds.py b/tests/unit/customizations/configure/test_exportcreds.py index 7980cc36cc12..3e5e00400aef 100644 --- a/tests/unit/customizations/configure/test_exportcreds.py +++ b/tests/unit/customizations/configure/test_exportcreds.py @@ -53,21 +53,24 @@ def __eq__(self, other): 'export AWS_SECRET_ACCESS_KEY=secret_key\n'), ('export AWS_ACCESS_KEY_ID=access_key\n' 'export AWS_SECRET_ACCESS_KEY=secret_key\n' - 'export AWS_SESSION_TOKEN=token\n'), + 'export AWS_SESSION_TOKEN=token\n' + 'export AWS_CREDENTIAL_EXPIRATION=2023-01-01T00:00:00Z\n'), )), (PowershellFormatter, ( ('$Env:AWS_ACCESS_KEY_ID="access_key"\n' '$Env:AWS_SECRET_ACCESS_KEY="secret_key"\n'), ('$Env:AWS_ACCESS_KEY_ID="access_key"\n' '$Env:AWS_SECRET_ACCESS_KEY="secret_key"\n' - '$Env:AWS_SESSION_TOKEN="token"\n'), + '$Env:AWS_SESSION_TOKEN="token"\n' + '$Env:AWS_CREDENTIAL_EXPIRATION=2023-01-01T00:00:00Z\n'), )), (WindowsCmdFormatter, ( ('set AWS_ACCESS_KEY_ID=access_key\n' 'set AWS_SECRET_ACCESS_KEY=secret_key\n'), ('set AWS_ACCESS_KEY_ID=access_key\n' 'set AWS_SECRET_ACCESS_KEY=secret_key\n' - 'set AWS_SESSION_TOKEN=token\n'), + 'set AWS_SESSION_TOKEN=token\n' + 'set AWS_CREDENTIAL_EXPIRATION=2023-01-01T00:00:00Z\n'), )), (CredentialProcessFormatter, ( JSONValue( From fd8b38317dd4a5c483d412c95b46abafe2bfdd2c Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Tue, 15 Nov 2022 14:05:05 -0500 Subject: [PATCH 04/10] Add changelog entry for export-creds command --- .changes/next-release/feature-credentials-32931.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/next-release/feature-credentials-32931.json diff --git a/.changes/next-release/feature-credentials-32931.json b/.changes/next-release/feature-credentials-32931.json new file mode 100644 index 000000000000..d2ed64cc4d62 --- /dev/null +++ b/.changes/next-release/feature-credentials-32931.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "credentials", + "description": "Add ``aws configure export-creds`` command (`issue 7388 `__)" +} From 280c39c16e7d03ed64bb7e53c326f5e01314ee33 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Tue, 15 Nov 2022 17:00:33 -0500 Subject: [PATCH 05/10] Rename command to 'export-credentials' --- awscli/customizations/configure/configure.py | 5 +++-- awscli/customizations/configure/exportcreds.py | 14 +++++++------- .../customizations/configure/test_exportcreds.py | 8 ++++---- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/awscli/customizations/configure/configure.py b/awscli/customizations/configure/configure.py index efe625247065..51d3b82fc037 100644 --- a/awscli/customizations/configure/configure.py +++ b/awscli/customizations/configure/configure.py @@ -26,7 +26,7 @@ from awscli.customizations.configure.listprofiles import ListProfilesCommand from awscli.customizations.configure.sso import ConfigureSSOCommand from awscli.customizations.configure.exportcreds import \ - ConfigureExportCredsCommand + ConfigureExportCredentialsCommand from . import mask_value, profile_to_section @@ -82,7 +82,8 @@ class ConfigureCommand(BasicCommand): {'name': 'import', 'command_class': ConfigureImportCommand}, {'name': 'list-profiles', 'command_class': ListProfilesCommand}, {'name': 'sso', 'command_class': ConfigureSSOCommand}, - {'name': 'export-creds', 'command_class': ConfigureExportCredsCommand}, + {'name': 'export-credentials', + 'command_class': ConfigureExportCredentialsCommand}, ] # If you want to add new values to prompt, update this list here. diff --git a/awscli/customizations/configure/exportcreds.py b/awscli/customizations/configure/exportcreds.py index 9f17ba458327..eaa1410d6fbd 100644 --- a/awscli/customizations/configure/exportcreds.py +++ b/awscli/customizations/configure/exportcreds.py @@ -139,9 +139,9 @@ def display_credentials(self, credentials): } -class ConfigureExportCredsCommand(BasicCommand): - NAME = 'export-creds' - SYNOPSIS = 'aws configure export-creds --profile profile-name' +class ConfigureExportCredentialsCommand(BasicCommand): + NAME = 'export-credentials' + SYNOPSIS = 'aws configure export-credentials --profile profile-name' ARG_TABLE = [ {'name': 'format', 'help_text': ( @@ -153,14 +153,14 @@ class ConfigureExportCredsCommand(BasicCommand): ] _RECURSION_VAR = '_AWS_CLI_PROFILE_CHAIN' # Two levels is reasonable because you might explicitly run - # "aws configure export-creds" with a profile that is configured - # with a credential_process of "aws configure export-creds". + # "aws configure export-credentials" with a profile that is configured + # with a credential_process of "aws configure export-credentials". # So we'll give one more level of recursion for padding and then # error out when we hit _MAX_RECURSION. _MAX_RECURSION = 4 def __init__(self, session, out_stream=None, error_stream=None, env=None): - super(ConfigureExportCredsCommand, self).__init__(session) + super(ConfigureExportCredentialsCommand, self).__init__(session) if out_stream is None: out_stream = sys.stdout if error_stream is None: @@ -211,7 +211,7 @@ def _run_main(self, parsed_args, parsed_globals): "Try setting an explicit '--profile' value in the " "'credential_process' configuration and ensure there " "are no cycles:\n\n" - "credential_process = aws configure export-creds " + "credential_process = aws configure export-credentials " "--profile other-profile\n" ) return 2 diff --git a/tests/unit/customizations/configure/test_exportcreds.py b/tests/unit/customizations/configure/test_exportcreds.py index 3e5e00400aef..7d871a29b498 100644 --- a/tests/unit/customizations/configure/test_exportcreds.py +++ b/tests/unit/customizations/configure/test_exportcreds.py @@ -26,7 +26,7 @@ from awscli.customizations.configure.exportcreds import ( Credentials, convert_botocore_credentials, - ConfigureExportCredsCommand, + ConfigureExportCredentialsCommand, BashEnvVarFormatter, PowershellFormatter, WindowsCmdFormatter, @@ -151,7 +151,7 @@ def test_no_expiry_time_if_non_datetime_value(self): ) -class TestConfigureExportCredsCommand(unittest.TestCase): +class TestConfigureExportCredentialsCommand(unittest.TestCase): def setUp(self): self.session = mock.Mock(spec=Session) self.session.emit_first_non_none_response.return_value = None @@ -159,7 +159,7 @@ def setUp(self): self.err_stream = io.StringIO() self.os_env = {} self.session.get_config_variable.return_value = 'default' - self.export_creds_cmd = ConfigureExportCredsCommand( + self.export_creds_cmd = ConfigureExportCredentialsCommand( self.session, self.out_stream, self.err_stream, env=self.os_env) self.global_args = mock.Mock() self.expiry = '2023-01-01T00:00:00Z' @@ -266,7 +266,7 @@ def test_detects_comma_char_with_cycle(self): rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) self.assertEqual(rc, 0) # Second time, it detects the cycle. - second_invoke = ConfigureExportCredsCommand( + second_invoke = ConfigureExportCredentialsCommand( self.session, self.out_stream, self.err_stream, env=self.os_env) rc = second_invoke(args=[], parsed_globals=self.global_args) self.assertIn( From 3ce953471cd485427abbb6658219fa972c68dedb Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Tue, 15 Nov 2022 17:06:11 -0500 Subject: [PATCH 06/10] Add no export env var format --- .../customizations/configure/exportcreds.py | 29 ++++++++++++++----- .../configure/test_exportcreds.py | 9 ++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/awscli/customizations/configure/exportcreds.py b/awscli/customizations/configure/exportcreds.py index eaa1410d6fbd..70be5e7bf87b 100644 --- a/awscli/customizations/configure/exportcreds.py +++ b/awscli/customizations/configure/exportcreds.py @@ -58,24 +58,37 @@ def display_credentials(self, credentials): pass -class BashEnvVarFormatter(BaseCredentialFormatter): +class BaseEnvVarFormatter(BaseCredentialFormatter): - FORMAT = 'env' + _VAR_PREFIX = '' def display_credentials(self, credentials): + prefix = self._VAR_PREFIX output = ( - f'export AWS_ACCESS_KEY_ID={credentials.access_key}\n' - f'export AWS_SECRET_ACCESS_KEY={credentials.secret_key}\n' + f'{prefix}AWS_ACCESS_KEY_ID={credentials.access_key}\n' + f'{prefix}AWS_SECRET_ACCESS_KEY={credentials.secret_key}\n' ) if credentials.token is not None: - output += f'export AWS_SESSION_TOKEN={credentials.token}\n' + output += f'{prefix}AWS_SESSION_TOKEN={credentials.token}\n' if credentials.expiry_time is not None: output += ( - f'export AWS_CREDENTIAL_EXPIRATION={credentials.expiry_time}\n' + f'{prefix}AWS_CREDENTIAL_EXPIRATION={credentials.expiry_time}\n' ) self._stream.write(output) +class BashEnvVarFormatter(BaseEnvVarFormatter): + + FORMAT = 'env' + _VAR_PREFIX = 'export ' + + +class BashNoExportEnvFormatter(BaseEnvVarFormatter): + + FORMAT = 'env-no-export' + _VAR_PREFIX = '' + + class PowershellFormatter(BaseCredentialFormatter): FORMAT = 'powershell' @@ -134,8 +147,8 @@ def display_credentials(self, credentials): SUPPORTED_FORMATS = { format_cls.FORMAT: format_cls for format_cls in - [BashEnvVarFormatter, CredentialProcessFormatter, PowershellFormatter, - WindowsCmdFormatter] + [BashEnvVarFormatter, BashNoExportEnvFormatter, CredentialProcessFormatter, + PowershellFormatter, WindowsCmdFormatter] } diff --git a/tests/unit/customizations/configure/test_exportcreds.py b/tests/unit/customizations/configure/test_exportcreds.py index 7d871a29b498..c7e12d7051df 100644 --- a/tests/unit/customizations/configure/test_exportcreds.py +++ b/tests/unit/customizations/configure/test_exportcreds.py @@ -28,6 +28,7 @@ convert_botocore_credentials, ConfigureExportCredentialsCommand, BashEnvVarFormatter, + BashNoExportEnvFormatter, PowershellFormatter, WindowsCmdFormatter, CredentialProcessFormatter, @@ -56,6 +57,14 @@ def __eq__(self, other): 'export AWS_SESSION_TOKEN=token\n' 'export AWS_CREDENTIAL_EXPIRATION=2023-01-01T00:00:00Z\n'), )), + (BashNoExportEnvFormatter, ( + ('AWS_ACCESS_KEY_ID=access_key\n' + 'AWS_SECRET_ACCESS_KEY=secret_key\n'), + ('AWS_ACCESS_KEY_ID=access_key\n' + 'AWS_SECRET_ACCESS_KEY=secret_key\n' + 'AWS_SESSION_TOKEN=token\n' + 'AWS_CREDENTIAL_EXPIRATION=2023-01-01T00:00:00Z\n'), + )), (PowershellFormatter, ( ('$Env:AWS_ACCESS_KEY_ID="access_key"\n' '$Env:AWS_SECRET_ACCESS_KEY="secret_key"\n'), From 9c3afea0e754ca3f93115cb5377041737b381164 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Wed, 16 Nov 2022 12:35:42 -0500 Subject: [PATCH 07/10] Fix changelog with new command --- .changes/next-release/feature-credentials-32931.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/next-release/feature-credentials-32931.json b/.changes/next-release/feature-credentials-32931.json index d2ed64cc4d62..218bfcaa41c7 100644 --- a/.changes/next-release/feature-credentials-32931.json +++ b/.changes/next-release/feature-credentials-32931.json @@ -1,5 +1,5 @@ { "type": "feature", "category": "credentials", - "description": "Add ``aws configure export-creds`` command (`issue 7388 `__)" + "description": "Add ``aws configure export-credentials`` command (`issue 7388 `__)" } From 6056e51341399179cfec4c110e20c80c765efd1a Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Wed, 16 Nov 2022 13:18:09 -0500 Subject: [PATCH 08/10] Add documentation for supported formats --- .../customizations/configure/exportcreds.py | 53 +++++++++++++++++-- .../configure/test_exportcreds.py | 2 +- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/awscli/customizations/configure/exportcreds.py b/awscli/customizations/configure/exportcreds.py index 70be5e7bf87b..90ddd2224be4 100644 --- a/awscli/customizations/configure/exportcreds.py +++ b/awscli/customizations/configure/exportcreds.py @@ -48,6 +48,7 @@ def convert_botocore_credentials(credentials): class BaseCredentialFormatter(object): FORMAT = None + DOCUMENTATION = "" def __init__(self, stream=None): if stream is None: @@ -80,18 +81,30 @@ def display_credentials(self, credentials): class BashEnvVarFormatter(BaseEnvVarFormatter): FORMAT = 'env' + DOCUMENTATION = ( + "Display credentials as exported shell variables: " + "``export AWS_ACCESS_KEY_ID=EXAMPLE``" + ) _VAR_PREFIX = 'export ' class BashNoExportEnvFormatter(BaseEnvVarFormatter): FORMAT = 'env-no-export' + DOCUMENTATION = ( + "Display credentials as non-exported shell variables: " + "``AWS_ACCESS_KEY_ID=EXAMPLE``" + ) _VAR_PREFIX = '' class PowershellFormatter(BaseCredentialFormatter): FORMAT = 'powershell' + DOCUMENTATION = ( + 'Display credentials as PowerShell environment variables: ' + '``$Env:AWS_ACCESS_KEY_ID="EXAMPLE"``' + ) def display_credentials(self, credentials): output = ( @@ -102,7 +115,7 @@ def display_credentials(self, credentials): output += f'$Env:AWS_SESSION_TOKEN="{credentials.token}"\n' if credentials.expiry_time is not None: output += ( - f'$Env:AWS_CREDENTIAL_EXPIRATION={credentials.expiry_time}\n' + f'$Env:AWS_CREDENTIAL_EXPIRATION="{credentials.expiry_time}"\n' ) self._stream.write(output) @@ -110,6 +123,10 @@ def display_credentials(self, credentials): class WindowsCmdFormatter(BaseCredentialFormatter): FORMAT = 'windows-cmd' + DOCUMENTATION = ( + 'Display credentials as Windows cmd environment variables: ' + '``set AWS_ACCESS_KEY_ID=EXAMPLE``' + ) def display_credentials(self, credentials): output = ( @@ -128,6 +145,14 @@ def display_credentials(self, credentials): class CredentialProcessFormatter(BaseCredentialFormatter): FORMAT = 'process' + DOCUMENTATION = ( + "Display credentials as JSON output, in the schema " + "expected by the ``credential_process`` config value." + "This enables any library or tool that supports " + "``credential_process`` to use the AWS CLI's credential " + "resolution process: ``credential_process = aws configure " + "export-credentials --profile myprofile``" + ) def display_credentials(self, credentials): output = { @@ -147,19 +172,37 @@ def display_credentials(self, credentials): SUPPORTED_FORMATS = { format_cls.FORMAT: format_cls for format_cls in - [BashEnvVarFormatter, BashNoExportEnvFormatter, CredentialProcessFormatter, + [CredentialProcessFormatter, BashEnvVarFormatter, BashNoExportEnvFormatter, PowershellFormatter, WindowsCmdFormatter] } +def generate_docs(formats): + lines = ['The output format to display credentials. ' + 'Defaults to `process`. ', '
    '] + for name, cls in formats.items(): + line = f'
  • ``{name}`` - {cls.DOCUMENTATION}
  • ' + lines.append(line) + lines.append('
') + return '\n'.join(lines) + + class ConfigureExportCredentialsCommand(BasicCommand): + NAME = 'export-credentials' SYNOPSIS = 'aws configure export-credentials --profile profile-name' + DESCRIPTION = ( + "Export credentials in various formats. This command will retrieve " + "AWS credentials using the AWS CLI's credential resolution process " + "and display the credentials in the specified ``--format``. By " + "default, the output format is ``process``, which is a JSON format " + "that's expected by the credential process feature supported by the " + "AWS SDKs and Tools. This command ignores the global ``--query`` and " + "``--output`` options." + ) ARG_TABLE = [ {'name': 'format', - 'help_text': ( - 'The output format to display credentials.' - 'Defaults to "process".'), + 'help_text': generate_docs(SUPPORTED_FORMATS), 'action': 'store', 'choices': list(SUPPORTED_FORMATS), 'default': CredentialProcessFormatter.FORMAT}, diff --git a/tests/unit/customizations/configure/test_exportcreds.py b/tests/unit/customizations/configure/test_exportcreds.py index c7e12d7051df..52d2b2dee7ef 100644 --- a/tests/unit/customizations/configure/test_exportcreds.py +++ b/tests/unit/customizations/configure/test_exportcreds.py @@ -71,7 +71,7 @@ def __eq__(self, other): ('$Env:AWS_ACCESS_KEY_ID="access_key"\n' '$Env:AWS_SECRET_ACCESS_KEY="secret_key"\n' '$Env:AWS_SESSION_TOKEN="token"\n' - '$Env:AWS_CREDENTIAL_EXPIRATION=2023-01-01T00:00:00Z\n'), + '$Env:AWS_CREDENTIAL_EXPIRATION="2023-01-01T00:00:00Z"\n'), )), (WindowsCmdFormatter, ( ('set AWS_ACCESS_KEY_ID=access_key\n' From b705644fd68eab694e3d969778e2d3888e2bae04 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Wed, 16 Nov 2022 13:29:29 -0500 Subject: [PATCH 09/10] Refactor per-line outputs to remove duplication --- .../customizations/configure/exportcreds.py | 61 ++++++------------- 1 file changed, 19 insertions(+), 42 deletions(-) diff --git a/awscli/customizations/configure/exportcreds.py b/awscli/customizations/configure/exportcreds.py index 90ddd2224be4..df09fd6bcea8 100644 --- a/awscli/customizations/configure/exportcreds.py +++ b/awscli/customizations/configure/exportcreds.py @@ -59,87 +59,64 @@ def display_credentials(self, credentials): pass -class BaseEnvVarFormatter(BaseCredentialFormatter): +class BasePerLineFormatter(BaseCredentialFormatter): - _VAR_PREFIX = '' + _VAR_FORMAT = 'export {var_name}={var_value}' def display_credentials(self, credentials): - prefix = self._VAR_PREFIX output = ( - f'{prefix}AWS_ACCESS_KEY_ID={credentials.access_key}\n' - f'{prefix}AWS_SECRET_ACCESS_KEY={credentials.secret_key}\n' - ) + self._format_line('AWS_ACCESS_KEY_ID', credentials.access_key) + + self._format_line('AWS_SECRET_ACCESS_KEY', credentials.secret_key)) if credentials.token is not None: - output += f'{prefix}AWS_SESSION_TOKEN={credentials.token}\n' + output += self._format_line('AWS_SESSION_TOKEN', credentials.token) if credentials.expiry_time is not None: - output += ( - f'{prefix}AWS_CREDENTIAL_EXPIRATION={credentials.expiry_time}\n' - ) + output += self._format_line( + 'AWS_CREDENTIAL_EXPIRATION', credentials.expiry_time) self._stream.write(output) + def _format_line(self, var_name, var_value): + return self._VAR_FORMAT.format( + var_name=var_name, var_value=var_value) + '\n' + -class BashEnvVarFormatter(BaseEnvVarFormatter): +class BashEnvVarFormatter(BasePerLineFormatter): FORMAT = 'env' DOCUMENTATION = ( "Display credentials as exported shell variables: " "``export AWS_ACCESS_KEY_ID=EXAMPLE``" ) - _VAR_PREFIX = 'export ' + _VAR_FORMAT = 'export {var_name}={var_value}' -class BashNoExportEnvFormatter(BaseEnvVarFormatter): +class BashNoExportEnvFormatter(BasePerLineFormatter): FORMAT = 'env-no-export' DOCUMENTATION = ( "Display credentials as non-exported shell variables: " "``AWS_ACCESS_KEY_ID=EXAMPLE``" ) - _VAR_PREFIX = '' + _VAR_FORMAT = '{var_name}={var_value}' -class PowershellFormatter(BaseCredentialFormatter): +class PowershellFormatter(BasePerLineFormatter): FORMAT = 'powershell' DOCUMENTATION = ( 'Display credentials as PowerShell environment variables: ' '``$Env:AWS_ACCESS_KEY_ID="EXAMPLE"``' ) + _VAR_FORMAT = '$Env:{var_name}="{var_value}"' - def display_credentials(self, credentials): - output = ( - f'$Env:AWS_ACCESS_KEY_ID="{credentials.access_key}"\n' - f'$Env:AWS_SECRET_ACCESS_KEY="{credentials.secret_key}"\n' - ) - if credentials.token is not None: - output += f'$Env:AWS_SESSION_TOKEN="{credentials.token}"\n' - if credentials.expiry_time is not None: - output += ( - f'$Env:AWS_CREDENTIAL_EXPIRATION="{credentials.expiry_time}"\n' - ) - self._stream.write(output) - -class WindowsCmdFormatter(BaseCredentialFormatter): +class WindowsCmdFormatter(BasePerLineFormatter): FORMAT = 'windows-cmd' DOCUMENTATION = ( 'Display credentials as Windows cmd environment variables: ' '``set AWS_ACCESS_KEY_ID=EXAMPLE``' ) - - def display_credentials(self, credentials): - output = ( - f'set AWS_ACCESS_KEY_ID={credentials.access_key}\n' - f'set AWS_SECRET_ACCESS_KEY={credentials.secret_key}\n' - ) - if credentials.token is not None: - output += f'set AWS_SESSION_TOKEN={credentials.token}\n' - if credentials.expiry_time is not None: - output += ( - f'set AWS_CREDENTIAL_EXPIRATION={credentials.expiry_time}\n' - ) - self._stream.write(output) + _VAR_FORMAT = 'set {var_name}={var_value}' class CredentialProcessFormatter(BaseCredentialFormatter): From 59ebc9c93cf4d6c059826cbbc7b233033f1d201d Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Wed, 16 Nov 2022 13:51:14 -0500 Subject: [PATCH 10/10] Improve error handling in export-credentials cmd * Use consistant RCs for config/credential fetching errors * Have separate error messages for cycles vs max recursion --- .../customizations/configure/exportcreds.py | 41 ++++++++--------- .../configure/test_exportcreds.py | 46 +++++++++---------- 2 files changed, 43 insertions(+), 44 deletions(-) diff --git a/awscli/customizations/configure/exportcreds.py b/awscli/customizations/configure/exportcreds.py index df09fd6bcea8..2358efca5405 100644 --- a/awscli/customizations/configure/exportcreds.py +++ b/awscli/customizations/configure/exportcreds.py @@ -19,6 +19,7 @@ from collections import namedtuple from awscli.customizations.commands import BasicCommand +from awscli.customizations.exceptions import ConfigurationError # Takes botocore's ReadOnlyCredentials and exposes an expiry_time. @@ -204,15 +205,23 @@ def __init__(self, session, out_stream=None, error_stream=None, env=None): self._error_stream = error_stream self._env = env - def _recursion_barrier_detected(self): + def _detect_recursion_barrier(self): profile = self._get_current_profile() seen_profiles = self._parse_profile_chain( self._env.get(self._RECURSION_VAR, '')) if len(seen_profiles) >= self._MAX_RECURSION: - return True - return profile in seen_profiles + raise ConfigurationError( + f"Maximum recursive credential process resolution reached " + f"({self._MAX_RECURSION}).\n" + f"Profiles seen: {' -> '.join(seen_profiles)}" + ) + if profile in seen_profiles: + raise ConfigurationError( + f"Credential process resolution detected an infinite loop, " + f"profile cycle: {' -> '.join(seen_profiles + [profile])}\n" + ) - def _set_recursion_barrier(self): + def _update_recursion_barrier(self): profile = self._get_current_profile() seen_profiles = self._parse_profile_chain( self._env.get(self._RECURSION_VAR, '')) @@ -238,27 +247,17 @@ def _parse_profile_chain(self, value): return result def _run_main(self, parsed_args, parsed_globals): - if self._recursion_barrier_detected(): - self._error_stream.write( - "\n\nRecursive credential resolution process detected.\n" - "Try setting an explicit '--profile' value in the " - "'credential_process' configuration and ensure there " - "are no cycles:\n\n" - "credential_process = aws configure export-credentials " - "--profile other-profile\n" - ) - return 2 - self._set_recursion_barrier() + self._detect_recursion_barrier() + self._update_recursion_barrier() try: creds = self._session.get_credentials() except Exception as e: - self._error_stream.write( - "Unable to retrieve credentials: %s\n" % e) - return 1 + original_msg = str(e).strip() + raise ConfigurationError( + f"Unable to retrieve credentials: {original_msg}\n") if creds is None: - self._error_stream.write( - "Unable to retrieve credentials: no credentials found\n") - return 1 + raise ConfigurationError( + "Unable to retrieve credentials: no credentials found") creds_with_expiry = convert_botocore_credentials(creds) formatter = SUPPORTED_FORMATS[parsed_args.format](self._out_stream) formatter.display_credentials(creds_with_expiry) diff --git a/tests/unit/customizations/configure/test_exportcreds.py b/tests/unit/customizations/configure/test_exportcreds.py index 52d2b2dee7ef..de94990ee96a 100644 --- a/tests/unit/customizations/configure/test_exportcreds.py +++ b/tests/unit/customizations/configure/test_exportcreds.py @@ -23,6 +23,7 @@ from botocore.session import Session from awscli.testutils import unittest +from awscli.customizations.exceptions import ConfigurationError from awscli.customizations.configure.exportcreds import ( Credentials, convert_botocore_credentials, @@ -199,29 +200,28 @@ def test_can_export_creds_explicit_format(self): def test_show_error_when_no_cred(self): self.session.get_credentials.return_value = None - rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + with pytest.raises(ConfigurationError) as excinfo: + self.export_creds_cmd(args=[], parsed_globals=self.global_args) self.assertIn( - 'Unable to retrieve credentials', self.err_stream.getvalue() - ) - self.assertEqual(rc, 1) + 'Unable to retrieve credentials', str(excinfo)) def test_show_error_when_cred_resolution_errors(self): self.session.get_credentials.side_effect = Exception( "resolution failed") - rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + with pytest.raises(ConfigurationError) as excinfo: + self.export_creds_cmd(args=[], parsed_globals=self.global_args) self.assertIn( - 'resolution failed', self.err_stream.getvalue() + 'resolution failed', str(excinfo) ) - self.assertEqual(rc, 1) def test_can_detect_recursive_resolution(self): self.os_env['_AWS_CLI_PROFILE_CHAIN'] = 'default' - rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + with pytest.raises(ConfigurationError) as excinfo: + self.export_creds_cmd(args=[], parsed_globals=self.global_args) self.assertIn( - 'Recursive credential resolution process detected', - self.err_stream.getvalue() + 'Credential process resolution detected an infinite loop', + str(excinfo), ) - self.assertEqual(rc, 2) def test_nested_calls_not_recursive(self): self.session.get_credentials.return_value = self.creds @@ -243,12 +243,12 @@ def test_nested_calls_with_cycle(self): self.session.get_credentials.return_value = self.creds self.os_env['_AWS_CLI_PROFILE_CHAIN'] = 'foo,bar,baz' self.session.get_config_variable.return_value = 'bar' - rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + with pytest.raises(ConfigurationError) as excinfo: + self.export_creds_cmd(args=[], parsed_globals=self.global_args) self.assertIn( - 'Recursive credential resolution process detected', - self.err_stream.getvalue() + 'Credential process resolution detected an infinite loop', + str(excinfo), ) - self.assertEqual(rc, 2) def test_handles_comma_char_in_profile_name_no_cycle(self): self.session.get_credentials.return_value = self.creds @@ -277,20 +277,20 @@ def test_detects_comma_char_with_cycle(self): # Second time, it detects the cycle. second_invoke = ConfigureExportCredentialsCommand( self.session, self.out_stream, self.err_stream, env=self.os_env) - rc = second_invoke(args=[], parsed_globals=self.global_args) + with pytest.raises(ConfigurationError) as excinfo: + second_invoke(args=[], parsed_globals=self.global_args) self.assertIn( - 'Recursive credential resolution process detected', - self.err_stream.getvalue() + 'Credential process resolution detected an infinite loop', + str(excinfo), ) - self.assertEqual(rc, 2) def test_max_recursion_limit(self): self.session.get_credentials.return_value = self.creds self.os_env['_AWS_CLI_PROFILE_CHAIN'] = ','.join( ['a', 'b', 'c', 'd', 'e', 'f', 'g']) - rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args) + with pytest.raises(ConfigurationError) as excinfo: + self.export_creds_cmd(args=[], parsed_globals=self.global_args) self.assertIn( - 'Recursive credential resolution process detected', - self.err_stream.getvalue() + 'Maximum recursive credential process resolution reached', + str(excinfo), ) - self.assertEqual(rc, 2)