diff --git a/src/azure-cli/azure/cli/command_modules/acs/custom.py b/src/azure-cli/azure/cli/command_modules/acs/custom.py index 02ec9df017d..06ca9920406 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/custom.py +++ b/src/azure-cli/azure/cli/command_modules/acs/custom.py @@ -2573,6 +2573,43 @@ def k8s_install_kubectl(cmd, client_version='latest', install_location=None, sou install_dir, cli) +# get the latest version of kubelogin +def _get_latest_kubelogin_version(cloud_name, gh_token=None): + if cloud_name.lower() == 'azurechinacloud': + latest_release_url = 'https://mirror.azure.cn/kubernetes/kubelogin/latest' + logger.warning( + 'No version specified, will get the latest version of kubelogin from "%s"', latest_release_url) + latest_release = _urlopen_read(latest_release_url, gh_token=gh_token) + return json.loads(latest_release)['tag_name'].strip() + + latest_release_url = 'https://api.github.com/repos/Azure/kubelogin/releases/latest' + fallback_url = 'https://github.com/Azure/kubelogin/releases/latest/download/kubelogin-version.txt' + logger.warning( + 'No version specified, will get the latest version of kubelogin from "%s"', latest_release_url) + try: + latest_release = _urlopen_read(latest_release_url, gh_token=gh_token) + return json.loads(latest_release)['tag_name'].strip() + except URLError as ex: + # the GitHub api answers with 403 or 429 when the rate limit is exceeded + if getattr(ex, 'code', None) not in (403, 429): + raise + logger.warning( + 'The GitHub api rate limit was exceeded (%s), getting the latest version of kubelogin from "%s"', + ex, fallback_url) + try: + latest_version = _urlopen_read(fallback_url).decode('UTF-8', errors='replace').strip() + except OSError as fallback_ex: + raise ClientRequestError( + 'Failed to get the latest version of kubelogin from "{}" ({}) and "{}" ({}).'.format( + latest_release_url, ex, fallback_url, fallback_ex), + recommendation='Please retry later, or specify a version with --kubelogin-version.') + if not re.fullmatch(r'v?\d+\.\d+\.\d+', latest_version): + raise ClientRequestError( + 'Unexpected version "{}" returned by "{}".'.format(latest_version[:50], fallback_url), + recommendation='Please retry later, or specify a version with --kubelogin-version.') + return latest_version if latest_version.startswith('v') else 'v' + latest_version + + # install kubelogin def k8s_install_kubelogin(cmd, client_version='latest', install_location=None, source_url=None, arch=None, gh_token=None): """ @@ -2587,13 +2624,7 @@ def k8s_install_kubelogin(cmd, client_version='latest', install_location=None, s source_url = 'https://mirror.azure.cn/kubernetes/kubelogin' if client_version == 'latest': - latest_release_url = 'https://api.github.com/repos/Azure/kubelogin/releases/latest' - if cloud_name.lower() == 'azurechinacloud': - latest_release_url = 'https://mirror.azure.cn/kubernetes/kubelogin/latest' - logger.warning( - 'No version specified, will get the latest version of kubelogin from "%s"', latest_release_url) - latest_release = _urlopen_read(latest_release_url, gh_token=gh_token) - client_version = json.loads(latest_release)['tag_name'].strip() + client_version = _get_latest_kubelogin_version(cloud_name, gh_token=gh_token) else: client_version = "v%s" % client_version diff --git a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_custom.py b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_custom.py index 2e12429ac29..50cb48971f8 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_custom.py +++ b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_custom.py @@ -8,6 +8,7 @@ import tempfile import unittest from unittest import mock +from urllib.error import HTTPError, URLError import datetime from dateutil.parser import parse @@ -25,6 +26,7 @@ ) from azure.cli.command_modules.acs.custom import ( _get_command_context, + _get_latest_kubelogin_version, _update_addons, aks_agentpool_auto_scale_add, aks_agentpool_auto_scale_delete, @@ -848,6 +850,104 @@ def test_k8s_install_kubelogin_with_gh_token(self, logger_mock, mock_url_retriev finally: shutil.rmtree(temp_dir) + @mock.patch('azure.cli.command_modules.acs.custom._urlopen_read') + @mock.patch('azure.cli.command_modules.acs.custom._urlretrieve') + @mock.patch('azure.cli.command_modules.acs.custom.logger') + def test_k8s_install_kubelogin_latest_version_fallback(self, logger_mock, mock_url_retrieve, mock_urlopen_read): + """Test that the version file is used to install kubelogin when the GitHub API is rate limited.""" + mock_urlopen_read.side_effect = [ + HTTPError('https://api.github.com/repos/Azure/kubelogin/releases/latest', 403, 'rate limited', None, None), + b'v0.0.30', + ] + mock_url_retrieve.side_effect = create_kubelogin_zip + + try: + temp_dir = tempfile.mkdtemp() + test_location = os.path.join(temp_dir, 'foo', 'kubelogin') + + k8s_install_kubelogin( + mock.MagicMock(), client_version='latest', install_location=test_location, + arch="amd64", gh_token='ghp_test_token_123') + + fallback_call = mock_urlopen_read.call_args_list[1] + self.assertEqual( + fallback_call[0][0], + 'https://github.com/Azure/kubelogin/releases/latest/download/kubelogin-version.txt') + self.assertIsNone(fallback_call.kwargs.get('gh_token')) + mock_url_retrieve.assert_called_with( + MockUrlretrieveUrlValidator('https://github.com/Azure/kubelogin/releases/download', 'v0.0.30'), + mock.ANY) + self.assertTrue( + any('rate limit was exceeded' in str(call) for call in logger_mock.warning.call_args_list)) + finally: + shutil.rmtree(temp_dir) + + @mock.patch('azure.cli.command_modules.acs.custom._urlopen_read') + @mock.patch('azure.cli.command_modules.acs.custom.logger') + def test_get_latest_kubelogin_version_fallback_only_on_rate_limit(self, logger_mock, mock_urlopen_read): + """Test that the version file is only used for a rate limit, other failures are surfaced as they are.""" + api_url = 'https://api.github.com/repos/Azure/kubelogin/releases/latest' + cases = [ + (HTTPError(api_url, 429, 'too many requests', None, None), True), + (HTTPError(api_url, 500, 'internal server error', None, None), False), + (URLError('[Errno -2] Name or service not known'), False), + ] + for error, expect_fallback in cases: + with self.subTest(error=error): + mock_urlopen_read.reset_mock() + mock_urlopen_read.side_effect = [error, b'v0.0.30'] + + if expect_fallback: + self.assertEqual(_get_latest_kubelogin_version('azurecloud'), 'v0.0.30') + self.assertEqual(mock_urlopen_read.call_count, 2) + else: + with self.assertRaises(type(error)) as cm: + _get_latest_kubelogin_version('azurecloud') + self.assertIs(cm.exception, error) + mock_urlopen_read.assert_called_once() + + @mock.patch('azure.cli.command_modules.acs.custom._urlopen_read') + @mock.patch('azure.cli.command_modules.acs.custom.logger') + def test_get_latest_kubelogin_version_fallback_without_tag_prefix(self, logger_mock, mock_urlopen_read): + """Test that a bare version is normalized to the release tag used to build the download url.""" + mock_urlopen_read.side_effect = [ + HTTPError('https://api.github.com/repos/Azure/kubelogin/releases/latest', 403, 'rate limited', None, None), + b'0.0.30\n', + ] + + self.assertEqual(_get_latest_kubelogin_version('azurecloud'), 'v0.0.30') + + @mock.patch('azure.cli.command_modules.acs.custom._urlopen_read') + @mock.patch('azure.cli.command_modules.acs.custom.logger') + def test_get_latest_kubelogin_version_all_sources_fail(self, logger_mock, mock_urlopen_read): + """Test that both failures are reported when the GitHub API and the version file are unavailable.""" + mock_urlopen_read.side_effect = [ + HTTPError('https://api.github.com/repos/Azure/kubelogin/releases/latest', 403, 'rate limited', None, None), + HTTPError('https://github.com/Azure/kubelogin/releases/latest/download/kubelogin-version.txt', + 500, 'internal server error', None, None), + ] + + with self.assertRaises(ClientRequestError) as cm: + _get_latest_kubelogin_version('azurecloud') + self.assertIn('403', str(cm.exception)) + self.assertIn('500', str(cm.exception)) + + @mock.patch('azure.cli.command_modules.acs.custom._urlopen_read') + @mock.patch('azure.cli.command_modules.acs.custom.logger') + def test_get_latest_kubelogin_version_unexpected_fallback_content(self, logger_mock, mock_urlopen_read): + """Test that content which is not exactly a version is rejected, not used to build the download url.""" + for content in (b'not found', b'v0.0.30/../../evil', b'\xff\xfe\x00binary'): + with self.subTest(content=content): + mock_urlopen_read.reset_mock() + mock_urlopen_read.side_effect = [ + HTTPError('https://api.github.com/repos/Azure/kubelogin/releases/latest', + 403, 'rate limited', None, None), + content, + ] + + with self.assertRaises(ClientRequestError): + _get_latest_kubelogin_version('azurecloud') + @mock.patch('azure.cli.command_modules.acs.addonconfiguration.get_rg_location', return_value='eastus') @mock.patch('azure.cli.command_modules.acs.addonconfiguration.get_resource_groups_client', autospec=True) @mock.patch('azure.cli.command_modules.acs.addonconfiguration.get_resources_client', autospec=True)