From 61eecced7a2642f946673f27e321a05950307ab0 Mon Sep 17 00:00:00 2001 From: Rong Jin Date: Fri, 31 Jul 2026 10:26:55 -0700 Subject: [PATCH] Add warning for outdated SessionManagerPlugin version --- .../enhancement-SSMSessionManager-91936.json | 5 + awscli/customizations/sessionmanager.py | 47 ++++++ .../customizations/test_sessionmanager.py | 155 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 .changes/next-release/enhancement-SSMSessionManager-91936.json diff --git a/.changes/next-release/enhancement-SSMSessionManager-91936.json b/.changes/next-release/enhancement-SSMSessionManager-91936.json new file mode 100644 index 000000000000..dd8914288e63 --- /dev/null +++ b/.changes/next-release/enhancement-SSMSessionManager-91936.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "SSM SessionManager", + "description": "Add warning message for outdated SessionManagerPlugin version" +} diff --git a/awscli/customizations/sessionmanager.py b/awscli/customizations/sessionmanager.py index cfbffe22a298..d07e0b450b37 100644 --- a/awscli/customizations/sessionmanager.py +++ b/awscli/customizations/sessionmanager.py @@ -15,10 +15,12 @@ import errno import os import re +import sys from subprocess import check_call, check_output from awscli.compat import ignore_user_entered_signals from awscli.clidriver import ServiceOperation, CLIOperationCaller +from awscli.customizations.utils import uni_print logger = logging.getLogger(__name__) @@ -29,6 +31,15 @@ 'session-manager-plugin-not-found' ) +OUTDATED_PLUGIN_VERSION_MESSAGE = ( + '\n' + 'WARNING: An outdated SessionManagerPlugin version detected. Please upgrade it to the latest version. \n' + 'For more information, refer:\n' + ' https://docs.aws.amazon.com/systems-manager/latest/userguide/' + 'session-manager-working-with-install-plugin.html\n' + '\n' +) + def register_ssm_session(event_handlers): event_handlers.register('building-command-table.ssm', @@ -63,6 +74,22 @@ def meets_requirement(self, version): else: return False + def meets_or_exceeds(self, version): + """Check a version against ``min_version`` inclusively.""" + ssm_plugin_version = self._sanitize_plugin_version(version) + if not self._is_valid_version(ssm_plugin_version): + return False + norm_version, norm_min_version = self._normalize( + ssm_plugin_version, self.min_version + ) + return norm_version >= norm_min_version + + def is_valid_plugin_version(self, version): + """Check whether the reported version string is parseable.""" + return self._is_valid_version( + self._sanitize_plugin_version(version) + ) + def _sanitize_plugin_version(self, plugin_version): return re.sub(self.WHITESPACE_REGEX, "", plugin_version) @@ -93,8 +120,24 @@ def create_help_command(self): class StartSessionCaller(CLIOperationCaller): LAST_PLUGIN_VERSION_WITHOUT_ENV_VAR = "1.2.497.0" + RECOMMENDED_MINIMUM_PLUGIN_VERSION = "1.2.764.0" DEFAULT_SSM_ENV_NAME = "AWS_SSM_START_SESSION_RESPONSE" + def _warn_if_plugin_version_is_outdated(self, plugin_version): + """Warn when the plugin is older than the recommended minimum.""" + version_requirement = VersionRequirement( + min_version=self.RECOMMENDED_MINIMUM_PLUGIN_VERSION + ) + if not version_requirement.is_valid_plugin_version(plugin_version): + logger.debug( + 'Unable to parse SessionManagerPlugin version %r, skipping ' + 'outdated version warning', plugin_version + ) + return + if version_requirement.meets_or_exceeds(plugin_version): + return + uni_print(OUTDATED_PLUGIN_VERSION_MESSAGE, sys.stderr) + def invoke(self, service_name, operation_name, parameters, parsed_globals): client = self._session.create_client( @@ -126,6 +169,10 @@ def invoke(self, service_name, operation_name, parameters, ) env = os.environ.copy() + # Warn, but do not fail, when the plugin is older than the + # recommended minimum version. + self._warn_if_plugin_version_is_outdated(plugin_version) + # Check if this plugin supports passing the start session response # as an environment variable name. If it does, it will set the # value to the response from the start_session operation to the env diff --git a/tests/unit/customizations/test_sessionmanager.py b/tests/unit/customizations/test_sessionmanager.py index b9e5e77d838f..e0948785d5b1 100644 --- a/tests/unit/customizations/test_sessionmanager.py +++ b/tests/unit/customizations/test_sessionmanager.py @@ -15,6 +15,7 @@ import json import pytest import subprocess +import sys from awscli.customizations import sessionmanager from awscli.testutils import mock, unittest @@ -427,3 +428,157 @@ def test_sanitize_plugin_version(self, version, expected_result): def test_is_valid_version(self, version, expected_result): assert expected_result == \ self.version_requirement._is_valid_version(version) + + +class TestRecommendedMinimumVersionRequirement: + version_requirement = sessionmanager.VersionRequirement( + min_version="1.2.764.0" + ) + + @pytest.mark.parametrize( + "version, expected_result", + [ + # The first version with the capability must satisfy the check. + ("1.2.764.0", True), + ("1.2.764", True), + ("1.2.764.1", True), + ("1.2.765.0", True), + ("1.3", True), + ("2.0.0.0", True), + ("\r\n1.2. 764.0", True), + # Anything below the threshold does not. + ("1.2.763.9", False), + ("1.2.763", False), + ("1.2.497.0", False), + ("1.2", False), + ("1", False), + ("0.9.999.9", False), + # Unparseable versions never satisfy the check. + ("invalid_version", False), + ("", False), + ], + ) + def test_meets_or_exceeds(self, version, expected_result): + assert expected_result == \ + self.version_requirement.meets_or_exceeds(version) + + @pytest.mark.parametrize( + "version, expected_result", + [ + ("1.2.764.0", True), + ("\r\n1.2.764.0\n", True), + ("1.2.764", True), + ("invalid_version", False), + ("", False), + ("1.1.1.1.1", False), + ], + ) + def test_is_valid_plugin_version(self, version, expected_result): + assert expected_result == \ + self.version_requirement.is_valid_plugin_version(version) + + +class TestOutdatedPluginVersionWarning(unittest.TestCase): + + def setUp(self): + self.session = mock.Mock(botocore.session.Session) + self.client = mock.Mock() + self.region = 'us-west-2' + self.endpoint_url = 'testUrl' + self.client.meta.region_name = self.region + self.client.meta.endpoint_url = self.endpoint_url + self.session.create_client.return_value = self.client + self.caller = sessionmanager.StartSessionCaller(self.session) + + self.parsed_globals = mock.Mock() + self.parsed_globals.profile = 'user_profile' + + self.start_session_params = {"Target": "i-123456789"} + self.client.start_session.return_value = { + "SessionId": "session-id", + "TokenValue": "token-value", + "StreamUrl": "stream-url", + } + + def _invoke_with_plugin_version(self, plugin_version): + with mock.patch( + 'awscli.customizations.sessionmanager.check_output' + ) as mock_check_output, mock.patch( + 'awscli.customizations.sessionmanager.check_call' + ) as mock_check_call, mock.patch( + 'awscli.customizations.sessionmanager.uni_print' + ) as mock_uni_print: + mock_check_output.return_value = plugin_version + mock_check_call.return_value = 0 + rc = self.caller.invoke( + 'ssm', 'StartSession', self.start_session_params, + self.parsed_globals + ) + return rc, mock_uni_print + + def _warning_messages(self, mock_uni_print): + return [ + call_args[0][0] for call_args in mock_uni_print.call_args_list + if call_args[0] + and call_args[0][0] == + sessionmanager.OUTDATED_PLUGIN_VERSION_MESSAGE + ] + + def test_warns_when_plugin_version_is_below_threshold(self): + rc, mock_uni_print = self._invoke_with_plugin_version("1.2.763.0\n") + # The warning is advisory only and must not fail the request. + self.assertEqual(rc, 0) + self.assertEqual(len(self._warning_messages(mock_uni_print)), 1) + mock_uni_print.assert_called_with( + sessionmanager.OUTDATED_PLUGIN_VERSION_MESSAGE, sys.stderr + ) + + def test_warns_when_plugin_version_predates_env_var_support(self): + rc, mock_uni_print = self._invoke_with_plugin_version("1.2.0.0\n") + self.assertEqual(rc, 0) + self.assertEqual(len(self._warning_messages(mock_uni_print)), 1) + + def test_no_warning_at_exact_threshold_version(self): + rc, mock_uni_print = self._invoke_with_plugin_version("1.2.764.0\n") + self.assertEqual(rc, 0) + self.assertEqual(self._warning_messages(mock_uni_print), []) + + def test_no_warning_when_plugin_version_is_newer(self): + rc, mock_uni_print = self._invoke_with_plugin_version("1.2.765.0\n") + self.assertEqual(rc, 0) + self.assertEqual(self._warning_messages(mock_uni_print), []) + + def test_no_warning_when_plugin_version_is_unparseable(self): + rc, mock_uni_print = self._invoke_with_plugin_version( + "not_a_version\n" + ) + self.assertEqual(rc, 0) + self.assertEqual(self._warning_messages(mock_uni_print), []) + + def test_outdated_plugin_still_receives_start_session_response(self): + # An outdated plugin must keep the existing fallback behavior of + # receiving the response directly rather than via an env var. + with mock.patch( + 'awscli.customizations.sessionmanager.check_output' + ) as mock_check_output, mock.patch( + 'awscli.customizations.sessionmanager.check_call' + ) as mock_check_call, mock.patch( + 'awscli.customizations.sessionmanager.uni_print' + ): + mock_check_output.return_value = "1.2.0.0\n" + mock_check_call.return_value = 0 + rc = self.caller.invoke( + 'ssm', 'StartSession', self.start_session_params, + self.parsed_globals + ) + + self.assertEqual(rc, 0) + check_call_args = mock_check_call.call_args[0][0] + self.assertEqual( + json.loads(check_call_args[1]), + { + "SessionId": "session-id", + "TokenValue": "token-value", + "StreamUrl": "stream-url", + }, + )