From 77c53097e020e0c5e629774b406672ca158154dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:04:57 +0000 Subject: [PATCH 01/12] Initial plan From 21ed1f33f7f1f2f6c01ca0dd81a4b9de56ebad4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:08:52 +0000 Subject: [PATCH 02/12] test: add eventhubs namespace validation regression coverage --- .../test_eventhub_command_validation.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py diff --git a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py new file mode 100644 index 00000000000..86e1db91af4 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py @@ -0,0 +1,22 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import re +import unittest + +from azure.cli.command_modules.eventhubs.aaz.latest.eventhubs.eventhub._list import List + + +class EventHubCommandValidationTests(unittest.TestCase): + + def test_eventhub_list_allows_short_valid_namespace_names(self): + namespace_name_format = List._build_arguments_schema().namespace_name._fmt + + self.assertEqual(namespace_name_format._pattern, "^[a-zA-Z][a-zA-Z0-9-]{4,48}[a-zA-Z0-9]$") + self.assertEqual(namespace_name_format._min_length, 6) + self.assertEqual(namespace_name_format._max_length, 50) + self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, 'oooooo')) + self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, 'ooooooo')) + self.assertIsNone(re.fullmatch(namespace_name_format._pattern, 'ooooo')) From 8fd91b0de7cd41afd04526e12b6981a7dce0ce04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:09:18 +0000 Subject: [PATCH 03/12] test: clarify eventhubs namespace boundary cases --- .../tests/latest/test_eventhub_command_validation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py index 86e1db91af4..b5dcc59fc10 100644 --- a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py +++ b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py @@ -17,6 +17,6 @@ def test_eventhub_list_allows_short_valid_namespace_names(self): self.assertEqual(namespace_name_format._pattern, "^[a-zA-Z][a-zA-Z0-9-]{4,48}[a-zA-Z0-9]$") self.assertEqual(namespace_name_format._min_length, 6) self.assertEqual(namespace_name_format._max_length, 50) - self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, 'oooooo')) - self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, 'ooooooo')) - self.assertIsNone(re.fullmatch(namespace_name_format._pattern, 'ooooo')) + self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, 'ns0001')) + self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, 'ns00001')) + self.assertIsNone(re.fullmatch(namespace_name_format._pattern, 'ns001')) From 229dd3fd35cb1f24c0b16cd111a8433a05a7d7e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:09:45 +0000 Subject: [PATCH 04/12] test: cover eventhubs namespace length limits --- .../tests/latest/test_eventhub_command_validation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py index b5dcc59fc10..96141decb6d 100644 --- a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py +++ b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py @@ -13,10 +13,14 @@ class EventHubCommandValidationTests(unittest.TestCase): def test_eventhub_list_allows_short_valid_namespace_names(self): namespace_name_format = List._build_arguments_schema().namespace_name._fmt + max_length_name = 'n' + ('0' * 48) + '1' + over_max_length_name = 'n' + ('0' * 49) + '1' self.assertEqual(namespace_name_format._pattern, "^[a-zA-Z][a-zA-Z0-9-]{4,48}[a-zA-Z0-9]$") self.assertEqual(namespace_name_format._min_length, 6) self.assertEqual(namespace_name_format._max_length, 50) self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, 'ns0001')) self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, 'ns00001')) + self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, max_length_name)) self.assertIsNone(re.fullmatch(namespace_name_format._pattern, 'ns001')) + self.assertIsNone(re.fullmatch(namespace_name_format._pattern, over_max_length_name)) From a8f84050222073012de8618a5ce6d80cc26c6792 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:10:14 +0000 Subject: [PATCH 05/12] test: tighten eventhubs namespace boundary assertions --- .../test_eventhub_command_validation.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py index 96141decb6d..96a6ca39d89 100644 --- a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py +++ b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py @@ -13,14 +13,19 @@ class EventHubCommandValidationTests(unittest.TestCase): def test_eventhub_list_allows_short_valid_namespace_names(self): namespace_name_format = List._build_arguments_schema().namespace_name._fmt - max_length_name = 'n' + ('0' * 48) + '1' - over_max_length_name = 'n' + ('0' * 49) + '1' + valid_min_length_namespace = 'ns0001' + valid_short_namespace = 'ns00001' + invalid_under_min_length_namespace = 'ns001' + valid_max_length_namespace = 'n' + ('0' * 48) + '1' + invalid_over_max_length_namespace = 'n' + ('0' * 49) + '1' self.assertEqual(namespace_name_format._pattern, "^[a-zA-Z][a-zA-Z0-9-]{4,48}[a-zA-Z0-9]$") self.assertEqual(namespace_name_format._min_length, 6) self.assertEqual(namespace_name_format._max_length, 50) - self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, 'ns0001')) - self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, 'ns00001')) - self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, max_length_name)) - self.assertIsNone(re.fullmatch(namespace_name_format._pattern, 'ns001')) - self.assertIsNone(re.fullmatch(namespace_name_format._pattern, over_max_length_name)) + self.assertEqual(len(valid_min_length_namespace), namespace_name_format._min_length) + self.assertEqual(len(valid_max_length_namespace), namespace_name_format._max_length) + self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, valid_min_length_namespace)) + self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, valid_short_namespace)) + self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, valid_max_length_namespace)) + self.assertIsNone(re.fullmatch(namespace_name_format._pattern, invalid_under_min_length_namespace)) + self.assertIsNone(re.fullmatch(namespace_name_format._pattern, invalid_over_max_length_namespace)) From 760bdce4a121e4b4112f9f6b0bcbd15e1244dc59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:10:38 +0000 Subject: [PATCH 06/12] test: rename eventhubs validation regression --- .../eventhubs/tests/latest/test_eventhub_command_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py index 96a6ca39d89..6a74064a3e5 100644 --- a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py +++ b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py @@ -11,7 +11,7 @@ class EventHubCommandValidationTests(unittest.TestCase): - def test_eventhub_list_allows_short_valid_namespace_names(self): + def test_eventhub_list_namespace_name_length_constraints(self): namespace_name_format = List._build_arguments_schema().namespace_name._fmt valid_min_length_namespace = 'ns0001' valid_short_namespace = 'ns00001' From c21fb3f6af2f0c0c32e8ee8532b6bb23a4cca1d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 04:16:22 +0000 Subject: [PATCH 07/12] test: remove credscan false positives from appservice mocks --- .../latest/test_webapp_commands_thru_mock.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 33da5ba3c12..0a360ee8f6f 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -1129,7 +1129,7 @@ def test_populate_cached_scm_headers_basic_auth_lowercase_key(self): from azure.cli.command_modules.appservice.custom import _populate_cached_scm_headers params = self._make_params() headers = { - 'authorization': 'Basic dXNlcjpwYXNz', + 'authorization': 'Basic mock-auth-header', 'User-Agent': 'AzureCLI/2.86.0', 'x-ms-client-request-id': 'req-1', 'Content-Type': 'application/octet-stream', @@ -1141,7 +1141,7 @@ def test_populate_cached_scm_headers_basic_auth_lowercase_key(self): # Lowercase key preserved (byte-equivalent to a fresh fetch on this # path). User-Agent included. Request id and content-type excluded. self.assertEqual(set(params._cached_scm_headers.keys()), {'authorization', 'User-Agent'}) - self.assertEqual(params._cached_scm_headers['authorization'], 'Basic dXNlcjpwYXNz') + self.assertEqual(params._cached_scm_headers['authorization'], 'Basic mock-auth-header') def test_populate_cached_scm_headers_aad_capitalized_key(self): # The AAD branch of get_scm_site_headers sets headers["Authorization"] @@ -1175,7 +1175,7 @@ def test_check_zip_deployment_status_reuses_cached_headers( from azure.cli.command_modules.appservice.custom import _check_zip_deployment_status params = self._make_params() params._cached_scm_headers = { - 'Authorization': 'Basic Y2FjaGVk', + 'Authorization': 'Basic cached-auth-header', 'User-Agent': 'AzureCLI/test', } # If the cache is honored, get_scm_site_headers must not be called. @@ -1195,7 +1195,7 @@ def test_check_zip_deployment_status_reuses_cached_headers( self.assertEqual(result.get('status'), 4) # Auth + UA reused from cache; request id refreshed from cmd. sent_headers = requests_get_mock.call_args.kwargs['headers'] - self.assertEqual(sent_headers['Authorization'], 'Basic Y2FjaGVk') + self.assertEqual(sent_headers['Authorization'], 'Basic cached-auth-header') self.assertEqual(sent_headers['User-Agent'], 'AzureCLI/test') self.assertEqual(sent_headers['x-ms-client-request-id'], 'req-1') @@ -1212,7 +1212,7 @@ def test_check_zip_deployment_status_reuses_cached_headers_basic_auth( _populate_cached_scm_headers, _check_zip_deployment_status) params = self._make_params() _populate_cached_scm_headers(params, { - 'authorization': 'Basic dXNlcjpwYXNz', # lowercase from urllib3 + 'authorization': 'Basic mock-auth-header', # lowercase from urllib3 'User-Agent': 'AzureCLI/test', 'x-ms-client-request-id': 'publish-leg-id', 'Content-Type': 'application/octet-stream', @@ -1233,7 +1233,7 @@ def test_check_zip_deployment_status_reuses_cached_headers_basic_auth( sent_headers = requests_get_mock.call_args.kwargs['headers'] # Lowercase key faithfully forwarded — HTTP is case-insensitive so the # server treats this the same as 'Authorization'. - self.assertEqual(sent_headers['authorization'], 'Basic dXNlcjpwYXNz') + self.assertEqual(sent_headers['authorization'], 'Basic mock-auth-header') self.assertEqual(sent_headers['User-Agent'], 'AzureCLI/test') # Fresh request id, not the one from the publish leg. self.assertEqual(sent_headers['x-ms-client-request-id'], 'req-1') @@ -1263,7 +1263,7 @@ def test_check_zip_deployment_status_falls_back_when_no_cache( get_scm_site_headers_mock.assert_called_once_with(cmd.cli_ctx, 'myApp', 'myRG', None) @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', - return_value={'Authorization': 'Basic ZnJlc2g=', 'User-Agent': 'AzureCLI/test'}) + return_value={'Authorization': 'Basic fresh-auth-header', 'User-Agent': 'AzureCLI/test'}) @mock.patch('azure.cli.command_modules.appservice.custom.time.sleep') @mock.patch('requests.get') def test_check_zip_deployment_status_refreshes_on_401( @@ -1271,7 +1271,7 @@ def test_check_zip_deployment_status_refreshes_on_401( from azure.cli.command_modules.appservice.custom import _check_zip_deployment_status params = self._make_params() params._cached_scm_headers = { - 'Authorization': 'Basic c3RhbGU=', + 'Authorization': 'Basic stale-auth-header', 'User-Agent': 'AzureCLI/test', } @@ -1293,10 +1293,10 @@ def test_check_zip_deployment_status_refreshes_on_401( # (test setup); the refresh respects whatever the hint helper returns. get_scm_site_headers_mock.assert_called_once_with( params.cmd.cli_ctx, 'myApp', 'myRG', None, is_flex_hint=None) - self.assertEqual(params._cached_scm_headers['Authorization'], 'Basic ZnJlc2g=') + self.assertEqual(params._cached_scm_headers['Authorization'], 'Basic fresh-auth-header') # Second request used the fresh credentials. second_call_headers = requests_get_mock.call_args_list[1].kwargs['headers'] - self.assertEqual(second_call_headers['Authorization'], 'Basic ZnJlc2g=') + self.assertEqual(second_call_headers['Authorization'], 'Basic fresh-auth-header') @mock.patch('azure.cli.command_modules.appservice.custom._make_onedeploy_request') @mock.patch('azure.cli.command_modules.appservice.custom._update_artifact_type') @@ -1309,7 +1309,7 @@ def test_perform_onedeploy_internal_clears_cache_on_success( params.is_functionapp = False def _populate_and_succeed(_params): - _params._cached_scm_headers = {'Authorization': 'Basic c2VjcmV0'} + _params._cached_scm_headers = {'Authorization': 'Basic cached-secret-placeholder'} _params._cached_site = mock.MagicMock(name='site') return {'status': 'ok'} make_request_mock.side_effect = _populate_and_succeed @@ -1332,7 +1332,7 @@ def test_perform_onedeploy_internal_clears_cache_on_exception( params.is_functionapp = False def _populate_and_raise(_params): - _params._cached_scm_headers = {'Authorization': 'Basic c2VjcmV0'} + _params._cached_scm_headers = {'Authorization': 'Basic cached-secret-placeholder'} _params._cached_site = mock.MagicMock(name='site') raise RuntimeError('boom') make_request_mock.side_effect = _populate_and_raise @@ -1348,7 +1348,7 @@ def _populate_and_raise(_params): def test_one_deploy_params_repr_does_not_leak_credentials(self): from azure.cli.command_modules.appservice.custom import OneDeployParams params = OneDeployParams() - params._cached_scm_headers = {'Authorization': 'Basic c2VjcmV0'} + params._cached_scm_headers = {'Authorization': 'Basic cached-secret-placeholder'} # The default repr is the object id; it must not contain attribute # values. If a future change adds a __repr__/__str__ that serializes # the cache, this test fails so the reviewer is forced to think about From 8fde6956dbe14e0abbb16e079999359df19f0462 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:16:09 +0000 Subject: [PATCH 08/12] test: defer eventhubs schema import in validation test --- .../tests/latest/test_eventhub_command_validation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py index 6a74064a3e5..a60b44dc2cb 100644 --- a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py +++ b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py @@ -6,12 +6,12 @@ import re import unittest -from azure.cli.command_modules.eventhubs.aaz.latest.eventhubs.eventhub._list import List - class EventHubCommandValidationTests(unittest.TestCase): def test_eventhub_list_namespace_name_length_constraints(self): + from azure.cli.command_modules.eventhubs.aaz.latest.eventhubs.eventhub._list import List + namespace_name_format = List._build_arguments_schema().namespace_name._fmt valid_min_length_namespace = 'ns0001' valid_short_namespace = 'ns00001' From cb1f5cf9a1ddd8f8ad4809a227831930a689da80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:48:07 +0000 Subject: [PATCH 09/12] revert: restore appservice test file to remove out-of-scope changes Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- .../latest/test_webapp_commands_thru_mock.py | 590 +++++++++++++++++- 1 file changed, 575 insertions(+), 15 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 0a360ee8f6f..697496f307c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -5,6 +5,8 @@ import unittest from unittest import mock import os +import types +from collections.abc import Mapping from azure.core.exceptions import HttpResponseError @@ -13,7 +15,8 @@ from azure.cli.core.azclierror import (InvalidArgumentValueError, MutuallyExclusiveArgumentError, ArgumentUsageError, - AzureResponseError) + AzureResponseError, + ResourceNotFoundError) from azure.cli.command_modules.appservice.custom import (set_deployment_user, update_git_token, add_hostname, update_site_configs, @@ -37,6 +40,7 @@ update_webapp, list_startup_logs, show_startup_log, + troubleshoot_status, create_webapp) # pylint: disable=line-too-long @@ -968,6 +972,373 @@ def test_show_startup_log_404_with_instance(self, requests_get_mock, _scm_url_mo self.assertEqual(logger_mock.warning.call_args[0][1], 'lw0sdlwk000002') +class TestTroubleshootStatusMocked(unittest.TestCase): + """Tests for az webapp troubleshoot status (ARM siteStatus + SCM startuplogs/summary).""" + + def setUp(self): + is_linux_patch = mock.patch( + 'azure.cli.command_modules.appservice.custom.is_linux_webapp', + return_value=True) + client_factory_patch = mock.patch( + 'azure.cli.command_modules.appservice.custom.web_client_factory') + sub_id_patch = mock.patch( + 'azure.cli.core.commands.client_factory.get_subscription_id', + return_value='00000000-0000-0000-0000-000000000000') + self.client_factory_mock = client_factory_patch.start() + is_linux_patch.start() + sub_id_patch.start() + self.addCleanup(is_linux_patch.stop) + self.addCleanup(client_factory_patch.stop) + self.addCleanup(sub_id_patch.stop) + # troubleshoot_status pins to API version '2024-11-01' explicitly, so the + # SDK config value here just needs to be set to avoid a MagicMock leaking + # into unrelated call sites; it is not what shows up in the assertion URLs. + self.client_factory_mock.return_value._config.api_version = '2024-11-01' + + self.cmd = _get_test_cmd() + self.cmd.cli_ctx.cloud.endpoints.resource_manager = 'https://management.azure.com' + + @staticmethod + def _arm_response(items): + return {'properties': items} + + @staticmethod + def _instances_payload(mapping): + """Build an ARM /instances response from {hex_id: machineName} mapping.""" + return {'value': [{'name': hex_id, 'properties': {'machineName': mn}} + for hex_id, mn in mapping.items()]} + + @staticmethod + def _make_response(status_code=200, json_data=None, reason='', text=''): + resp = mock.MagicMock() + resp.status_code = status_code + resp.reason = reason + resp.text = text + resp.json.return_value = json_data + return resp + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('requests.get') + def test_troubleshoot_status_all_instances(self, requests_get_mock, send_raw_request_mock, + _scm_url_mock, _headers_mock): + arm_items = [ + {'instanceId': 'a3f1b', 'state': 'Started', 'action': 'SiteStarted', + 'lastError': None, 'lastErrorDetails': None, 'lastErrorTimestamp': None, + 'details': 'Site is running', 'detailsLevel': 'Information'}, + {'instanceId': 'b4d22', 'state': 'Starting', 'action': 'PullingImage', + 'lastError': None, 'lastErrorDetails': None, 'lastErrorTimestamp': None, + 'details': 'Pulling image', 'detailsLevel': 'Warning'}, + ] + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'a3f1b': 'lw0sdlwk0008PB', 'b4d22': 'lw1sdlwk0009EF'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_items))), + ] + # Real KuduLite response is a single list with one entry per instance. + a3f1b_startup = {'Succeeded': 1, 'Failed': 0} + b4d22_startup = {'Succeeded': 0, 'Failed': 3} + requests_get_mock.return_value = self._make_response(200, json_data=[ + {'InstanceId': 'lw0sdlwk0008PB', 'Startup': a3f1b_startup}, + {'InstanceId': 'lw1sdlwk0009EF', 'Startup': b4d22_startup}, + ]) + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp') + + self.assertEqual(result['instances'][0]['startup'], a3f1b_startup) + self.assertEqual(result['instances'][1]['startup'], b4d22_startup) + self.assertEqual(result['instances'][0]['machineName'], 'lw0sdlwk0008PB') + self.assertEqual(result['instances'][1]['machineName'], 'lw1sdlwk0009EF') + # ARM calls: instances FIRST (so we can resolve --instance), then siteStatus. + arm_urls = [call.args[2] for call in send_raw_request_mock.call_args_list] + self.assertEqual(arm_urls, [ + 'https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000' + '/resourceGroups/myRG/providers/Microsoft.Web/sites/myApp/instances?api-version=2024-11-01', + 'https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000' + '/resourceGroups/myRG/providers/Microsoft.Web/sites/myApp/siteStatus?api-version=2024-11-01', + ]) + # Single unfiltered SCM call returns every instance in one response. + requests_get_mock.assert_called_once_with( + 'https://myapp.scm.azurewebsites.net/api/startuplogs/summary', + headers={'Authorization': 'Bearer token'}, + timeout=30, + ) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('requests.get') + def test_troubleshoot_status_single_instance(self, requests_get_mock, send_raw_request_mock, + _scm_url_mock, _headers_mock): + arm_item = {'instanceId': '7c2d9', 'state': 'Stopped', 'action': 'SiteStopped', + 'lastError': 'NoResponse', 'lastErrorDetails': 'Worker not reachable', + 'lastErrorTimestamp': '2026-05-20T18:50:44Z', + 'details': 'Stopped', 'detailsLevel': 'Error'} + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'7c2d9': 'lw0sdlwk0007AB'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_item))), + ] + startup_summary = {'Succeeded': 0, 'Failed': 4} + requests_get_mock.return_value = self._make_response( + 200, json_data=[{'InstanceId': 'lw0sdlwk0007AB', 'Startup': startup_summary}]) + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp', instance='7c2d9') + + self.assertEqual(result['instances'][0]['instanceId'], '7c2d9') + self.assertEqual(result['instances'][0]['startup'], startup_summary) + self.assertEqual(result['instances'][0]['machineName'], 'lw0sdlwk0007AB') + arm_urls = [call.args[2] for call in send_raw_request_mock.call_args_list] + self.assertEqual(arm_urls, [ + 'https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000' + '/resourceGroups/myRG/providers/Microsoft.Web/sites/myApp/instances?api-version=2024-11-01', + 'https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000' + '/resourceGroups/myRG/providers/Microsoft.Web/sites/myApp/siteStatus/7c2d9' + '?api-version=2024-11-01', + ]) + requests_get_mock.assert_called_once_with( + 'https://myapp.scm.azurewebsites.net/api/startuplogs/summary?instance=lw0sdlwk0007AB', + headers={'Authorization': 'Bearer token'}, + timeout=30, + ) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('requests.get') + def test_troubleshoot_status_summary_404_returns_empty_startup( + self, requests_get_mock, send_raw_request_mock, _scm_url_mock, _headers_mock): + arm_items = [{'instanceId': 'abcde', 'state': 'Started', 'action': 'SiteStarted'}] + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'abcde': 'lw0sdlwk0001AA'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_items))), + ] + requests_get_mock.return_value = self._make_response(404) + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp') + + # A 404 from the /api/startuplogs/summary endpoint means the KuduLite + # build doesn't recognize the route yet (feature not rolled out). + # Surface that as a SummaryFetchStatus so users aren't misled into + # thinking their site had no startup attempts. + startup = result['instances'][0]['startup'] + self.assertIsNotNone(startup) + self.assertIn('Startup summary is not available for this app', startup.get('SummaryFetchStatus', '')) + self.assertIn("not rolled out to your app's region yet", startup.get('SummaryFetchStatus', '')) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('requests.get') + def test_troubleshoot_status_summary_400_invalid_filename_surfaces_message( + self, requests_get_mock, send_raw_request_mock, _scm_url_mock, _headers_mock): + # Older KuduLite build routes /api/startuplogs/{filename} and has no + # /summary sub-route, so it returns 400 "Invalid startup log filename." + # This should surface as feature-not-available, not as "no startups". + arm_items = [{'instanceId': 'abcde', 'state': 'Started', 'action': 'SiteStarted'}] + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'abcde': 'lw0sdlwk0001AA'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_items))), + ] + requests_get_mock.return_value = self._make_response( + 400, reason='BadRequest', text='Invalid startup log filename.') + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp') + + startup = result['instances'][0]['startup'] + self.assertIsNotNone(startup) + msg = startup.get('SummaryFetchStatus', '') + self.assertIn('Startup summary is not available for this app', msg) + self.assertIn("not rolled out to your app's region yet", msg) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('requests.get') + def test_troubleshoot_status_summary_request_exception_surfaces_transport_error( + self, requests_get_mock, send_raw_request_mock, _scm_url_mock, _headers_mock): + # Regression: when requests.get raises a transport-level exception + # (ConnectionError, timeout, TLS failure) the previous code left + # SummaryFetchStatus unset, so callers couldn't tell whether SCM was + # simply healthy-with-no-startups or unreachable. Ensure we surface a + # meaningful message including the exception class. + import requests as _requests + arm_items = [{'instanceId': 'abcde', 'state': 'Started', 'action': 'SiteStarted'}] + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'abcde': 'lw0sdlwk0001AA'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_items))), + ] + requests_get_mock.side_effect = _requests.ConnectionError('boom') + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp') + + startup = result['instances'][0]['startup'] + self.assertIsNotNone(startup) + msg = startup.get('SummaryFetchStatus', '') + self.assertIn('Failed to reach SCM startup summary endpoint', msg) + self.assertIn('ConnectionError', msg) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + def test_troubleshoot_status_arm_404_with_instance(self, send_raw_request_mock, + _scm_url_mock, _headers_mock): + error = HttpResponseError(message='Not found') + error.status_code = 404 + send_raw_request_mock.side_effect = error + + with self.assertRaises(ResourceNotFoundError): + troubleshoot_status(self.cmd, 'myRG', 'myApp', instance='7c2d9') + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('requests.get') + def test_troubleshoot_status_summary_500_surfaces_message( + self, requests_get_mock, send_raw_request_mock, _scm_url_mock, _headers_mock): + arm_items = [{'instanceId': 'abcde', 'state': 'Started', 'action': 'SiteStarted'}] + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'abcde': 'lw0sdlwk0001AA'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_items))), + ] + requests_get_mock.return_value = self._make_response(500, reason='Internal Server Error') + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp') + + # Non-200 -> feature-not-available message, not silent drop. + startup = result['instances'][0]['startup'] + self.assertIsNotNone(startup) + self.assertIn('Startup summary is not available for this app', startup.get('SummaryFetchStatus', '')) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('requests.get') + def test_troubleshoot_status_machine_name_as_instance( + self, requests_get_mock, send_raw_request_mock, _scm_url_mock, _headers_mock): + """User passes a friendly machineName for --instance; we should resolve it to + the hex ARM instanceId before calling /siteStatus.""" + arm_item = {'instanceId': '7c2d9', 'state': 'Started', 'action': 'SiteStarted'} + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'7c2d9': 'lw0sdlwk0007AB'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_item))), + ] + requests_get_mock.return_value = self._make_response( + 200, json_data=[{'InstanceId': 'lw0sdlwk0007AB', + 'Startup': {'Succeeded': 1, 'Failed': 0}}]) + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp', instance='lw0sdlwk0007AB') + + # ARM /siteStatus must use the hex id even though user passed the machine name. + arm_urls = [call.args[2] for call in send_raw_request_mock.call_args_list] + self.assertIn('/siteStatus/7c2d9?', arm_urls[1]) + self.assertEqual(result['instances'][0]['machineName'], 'lw0sdlwk0007AB') + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + def test_troubleshoot_status_unknown_instance_raises( + self, send_raw_request_mock, _scm_url_mock, _headers_mock): + """User passes an --instance that matches neither hex id nor machineName.""" + send_raw_request_mock.return_value = mock.MagicMock( + json=mock.MagicMock(return_value=self._instances_payload({'7c2d9': 'lw0sdlwk0007AB'}))) + + with self.assertRaises(ResourceNotFoundError): + troubleshoot_status(self.cmd, 'myRG', 'myApp', instance='does-not-exist') + + def test_troubleshoot_status_raises_on_windows(self): + with mock.patch('azure.cli.command_modules.appservice.custom.is_linux_webapp', + return_value=False): + with self.assertRaises(ArgumentUsageError) as cm: + troubleshoot_status(self.cmd, 'myRG', 'myWindowsApp') + self.assertIn('Linux', str(cm.exception)) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('requests.get') + @mock.patch('azure.cli.command_modules.appservice._troubleshoot_status_report.render_report') + def test_troubleshoot_status_report_flag_renders_and_returns_none( + self, render_mock, requests_get_mock, send_raw_request_mock, + _scm_url_mock, _headers_mock): + """With --report, command calls the renderer and returns None (no structured payload).""" + arm_item = {'instanceId': '7c2d9', 'state': 'Running'} + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'7c2d9': 'lw0sdlwk0007AB'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_item))), + ] + requests_get_mock.return_value = self._make_response( + 200, json_data=[{'InstanceId': 'lw0sdlwk0007AB', + 'Startup': {'Succeeded': 1, 'Failed': 0}}]) + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp', report=True) + + self.assertIsNone(result) + render_mock.assert_called_once() + rendered_payload = render_mock.call_args.args[0] + self.assertEqual(rendered_payload['name'], 'myApp') + self.assertEqual(rendered_payload['instances'][0]['instanceId'], '7c2d9') + + def test_transform_troubleshoot_status_output_renders_error_columns(self): + # Regression: the LastError* columns exercise _format_dt only when + # any instance has a visible error. A missing import there surfaces + # as knack's opaque "Table output unavailable" message. + from azure.cli.command_modules.appservice.commands import ( + transform_troubleshoot_status_output, + ) + payload = { + 'name': 'myApp', + 'resourceGroup': 'myRG', + 'instances': [ + { + 'instanceId': 'b6cc022ee0e1234567890', + 'state': 'Stopped', + 'details': 'container did not start', + 'lastError': 'ContainerTimeout', + 'lastErrorTimestamp': '2026-07-13T17:29:10Z', + 'lastErrorDetails': 'boom', + 'startup': {'Succeeded': 1, 'Failed': 2}, + }, + ], + } + rows = transform_troubleshoot_status_output(payload) + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(row['InstanceId'], 'b6cc022ee0') + self.assertEqual(row['LastError'], 'ContainerTimeout') + self.assertIn('2026-07-13', row['LastErrorTimestamp']) + self.assertEqual(row['LastErrorDetails'], 'boom') + self.assertEqual(row['Succeeded (last 24h)'], 1) + self.assertEqual(row['Failed (last 24h)'], 2) + + class TestRuntimeFailedHintMocked(unittest.TestCase): """Tests that the TIP hint appears in RuntimeFailed and timeout errors.""" @@ -1129,7 +1500,7 @@ def test_populate_cached_scm_headers_basic_auth_lowercase_key(self): from azure.cli.command_modules.appservice.custom import _populate_cached_scm_headers params = self._make_params() headers = { - 'authorization': 'Basic mock-auth-header', + 'authorization': 'Basic ****', 'User-Agent': 'AzureCLI/2.86.0', 'x-ms-client-request-id': 'req-1', 'Content-Type': 'application/octet-stream', @@ -1141,7 +1512,7 @@ def test_populate_cached_scm_headers_basic_auth_lowercase_key(self): # Lowercase key preserved (byte-equivalent to a fresh fetch on this # path). User-Agent included. Request id and content-type excluded. self.assertEqual(set(params._cached_scm_headers.keys()), {'authorization', 'User-Agent'}) - self.assertEqual(params._cached_scm_headers['authorization'], 'Basic mock-auth-header') + self.assertEqual(params._cached_scm_headers['authorization'], 'Basic ****') def test_populate_cached_scm_headers_aad_capitalized_key(self): # The AAD branch of get_scm_site_headers sets headers["Authorization"] @@ -1175,7 +1546,7 @@ def test_check_zip_deployment_status_reuses_cached_headers( from azure.cli.command_modules.appservice.custom import _check_zip_deployment_status params = self._make_params() params._cached_scm_headers = { - 'Authorization': 'Basic cached-auth-header', + 'Authorization': 'Basic ****', 'User-Agent': 'AzureCLI/test', } # If the cache is honored, get_scm_site_headers must not be called. @@ -1195,7 +1566,7 @@ def test_check_zip_deployment_status_reuses_cached_headers( self.assertEqual(result.get('status'), 4) # Auth + UA reused from cache; request id refreshed from cmd. sent_headers = requests_get_mock.call_args.kwargs['headers'] - self.assertEqual(sent_headers['Authorization'], 'Basic cached-auth-header') + self.assertEqual(sent_headers['Authorization'], 'Basic ****') self.assertEqual(sent_headers['User-Agent'], 'AzureCLI/test') self.assertEqual(sent_headers['x-ms-client-request-id'], 'req-1') @@ -1212,7 +1583,7 @@ def test_check_zip_deployment_status_reuses_cached_headers_basic_auth( _populate_cached_scm_headers, _check_zip_deployment_status) params = self._make_params() _populate_cached_scm_headers(params, { - 'authorization': 'Basic mock-auth-header', # lowercase from urllib3 + 'authorization': 'Basic ****', # lowercase from urllib3 'User-Agent': 'AzureCLI/test', 'x-ms-client-request-id': 'publish-leg-id', 'Content-Type': 'application/octet-stream', @@ -1233,7 +1604,7 @@ def test_check_zip_deployment_status_reuses_cached_headers_basic_auth( sent_headers = requests_get_mock.call_args.kwargs['headers'] # Lowercase key faithfully forwarded — HTTP is case-insensitive so the # server treats this the same as 'Authorization'. - self.assertEqual(sent_headers['authorization'], 'Basic mock-auth-header') + self.assertEqual(sent_headers['authorization'], 'Basic ****') self.assertEqual(sent_headers['User-Agent'], 'AzureCLI/test') # Fresh request id, not the one from the publish leg. self.assertEqual(sent_headers['x-ms-client-request-id'], 'req-1') @@ -1263,7 +1634,7 @@ def test_check_zip_deployment_status_falls_back_when_no_cache( get_scm_site_headers_mock.assert_called_once_with(cmd.cli_ctx, 'myApp', 'myRG', None) @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', - return_value={'Authorization': 'Basic fresh-auth-header', 'User-Agent': 'AzureCLI/test'}) + return_value={'Authorization': 'Basic ****', 'User-Agent': 'AzureCLI/test'}) @mock.patch('azure.cli.command_modules.appservice.custom.time.sleep') @mock.patch('requests.get') def test_check_zip_deployment_status_refreshes_on_401( @@ -1271,7 +1642,7 @@ def test_check_zip_deployment_status_refreshes_on_401( from azure.cli.command_modules.appservice.custom import _check_zip_deployment_status params = self._make_params() params._cached_scm_headers = { - 'Authorization': 'Basic stale-auth-header', + 'Authorization': 'Basic ****', 'User-Agent': 'AzureCLI/test', } @@ -1293,10 +1664,10 @@ def test_check_zip_deployment_status_refreshes_on_401( # (test setup); the refresh respects whatever the hint helper returns. get_scm_site_headers_mock.assert_called_once_with( params.cmd.cli_ctx, 'myApp', 'myRG', None, is_flex_hint=None) - self.assertEqual(params._cached_scm_headers['Authorization'], 'Basic fresh-auth-header') + self.assertEqual(params._cached_scm_headers['Authorization'], 'Basic ****') # Second request used the fresh credentials. second_call_headers = requests_get_mock.call_args_list[1].kwargs['headers'] - self.assertEqual(second_call_headers['Authorization'], 'Basic fresh-auth-header') + self.assertEqual(second_call_headers['Authorization'], 'Basic ****') @mock.patch('azure.cli.command_modules.appservice.custom._make_onedeploy_request') @mock.patch('azure.cli.command_modules.appservice.custom._update_artifact_type') @@ -1309,7 +1680,7 @@ def test_perform_onedeploy_internal_clears_cache_on_success( params.is_functionapp = False def _populate_and_succeed(_params): - _params._cached_scm_headers = {'Authorization': 'Basic cached-secret-placeholder'} + _params._cached_scm_headers = {'Authorization': 'Basic ****'} _params._cached_site = mock.MagicMock(name='site') return {'status': 'ok'} make_request_mock.side_effect = _populate_and_succeed @@ -1332,7 +1703,7 @@ def test_perform_onedeploy_internal_clears_cache_on_exception( params.is_functionapp = False def _populate_and_raise(_params): - _params._cached_scm_headers = {'Authorization': 'Basic cached-secret-placeholder'} + _params._cached_scm_headers = {'Authorization': 'Basic ****'} _params._cached_site = mock.MagicMock(name='site') raise RuntimeError('boom') make_request_mock.side_effect = _populate_and_raise @@ -1348,7 +1719,7 @@ def _populate_and_raise(_params): def test_one_deploy_params_repr_does_not_leak_credentials(self): from azure.cli.command_modules.appservice.custom import OneDeployParams params = OneDeployParams() - params._cached_scm_headers = {'Authorization': 'Basic cached-secret-placeholder'} + params._cached_scm_headers = {'Authorization': 'Basic ****'} # The default repr is the object id; it must not contain attribute # values. If a future change adds a __repr__/__str__ that serializes # the cache, this test fails so the reviewer is forced to think about @@ -1605,5 +1976,194 @@ def test_get_visit_url_falls_back_when_no_cache(self, get_url_mock): get_url_mock.assert_called_once_with(params.cmd, 'myRG', 'myApp', None) +class _TypespecContainerSettings(Mapping): + """Mimics an azure-mgmt-web typespec/DPG container settings model. + + The current SDK returns models that behave like a read-only ``Mapping`` keyed + by the raw camelCase API field names (e.g. ``runtimes``, ``isAutoUpdate``, + ``java25Runtime``). They also expose the few fields the SDK explicitly models + as snake_case attributes (``java8_runtime``/``java11_runtime``). Crucially, + unknown fields are NOT surfaced via the old msrest ``additional_properties`` + dict -- that attribute stays empty. The list-runtimes regression came from + reading only the typed attributes / ``additional_properties`` (which together + cover at most Java 8/11) instead of the Mapping data, silently dropping + Java 17/21/25. + """ + + def __init__(self, data, *, java8=None, java11=None, is_auto_update=False, + end_of_life_date=None): + self._data = dict(data) + self.java8_runtime = java8 + self.java11_runtime = java11 + self.is_auto_update = is_auto_update + self.end_of_life_date = end_of_life_date + self.is_hidden = False + self.is_deprecated = False + # Typespec models leave additional_properties empty (msrest-only concept). + self.additional_properties = [] + + def __getitem__(self, key): + return self._data[key] + + def __iter__(self): + return iter(self._data) + + def __len__(self): + return len(self._data) + + +class TestStackRuntimeJavaSELinux(unittest.TestCase): + """Regression tests for `az webapp list-runtimes` Linux Java SE parsing. + + The displayed Linux Java SE runtimes come from a single aggregate auto-update + container whose ``runtimes`` array enumerates every available Java major + version (8/11/17/21/25). The azure-mgmt-web SDK now returns typespec models + that preserve those fields via the Mapping interface rather than the typed + java8/java11 attributes or msrest ``additional_properties``. These tests guard + against the regression where newer Java versions were dropped because only the + typed attributes / ``additional_properties`` were consulted, and against the + aggregate container's position in the response mattering. + """ + + EXPECTED = { + 'JAVA|25-java25', 'JAVA|21-java21', 'JAVA|17-java17', 'JAVA|11-java11', 'JAVA|8-jre8', + } + + FULL_RUNTIMES = [ + {'runtimeVersion': '8', 'runtime': 'JAVA|8-jre8'}, + {'runtimeVersion': '11', 'runtime': 'JAVA|11-java11'}, + {'runtimeVersion': '17', 'runtime': 'JAVA|17-java17'}, + {'runtimeVersion': '21', 'runtime': 'JAVA|21-java21'}, + {'runtimeVersion': '25', 'runtime': 'JAVA|25-java25'}, + ] + + @staticmethod + def _minor(value, container_settings): + stack_settings = types.SimpleNamespace( + linux_container_settings=container_settings, + linux_runtime_settings=None, + windows_container_settings=None, + windows_runtime_settings=None, + ) + return types.SimpleNamespace(value=value, stack_settings=stack_settings) + + def _patch_minors(self): + # Per-patch Java SE minors are always present in the API response, one per + # build. They are NOT auto-update and must never drive the displayed output. + return [ + self._minor('25.0.1', _TypespecContainerSettings( + {'runtimes': [{'runtimeVersion': '25', 'runtime': 'JAVA|25.0.1'}]})), + self._minor('21.0.9', _TypespecContainerSettings( + {'runtimes': [{'runtimeVersion': '21', 'runtime': 'JAVA|21.0.9'}]})), + self._minor('17.0.17', _TypespecContainerSettings( + {'runtimes': [{'runtimeVersion': '17', 'runtime': 'JAVA|17.0.17'}]})), + self._minor('11.0.29', _TypespecContainerSettings( + {'runtimes': [{'runtimeVersion': '11', 'runtime': 'JAVA|11.0.29'}]})), + self._minor('1.8.472', _TypespecContainerSettings( + {'runtimes': [{'runtimeVersion': '8', 'runtime': 'JAVA|1.8.472'}]})), + ] + + @staticmethod + def _java_se_stack(minors): + major = types.SimpleNamespace( + display_text='Java SE (Embedded Web Server)', + minor_versions=minors, + ) + return types.SimpleNamespace(display_text='Java Containers', major_versions=[major]) + + @staticmethod + def _new_helper(): + from azure.cli.command_modules.appservice.custom import _StackRuntimeHelper + helper = _StackRuntimeHelper.__new__(_StackRuntimeHelper) + helper._linux = True + helper._windows = False + helper._include_eol = False + helper._stacks = [] + helper.windows_config_mappings = {'node': None} + return helper + + def _java_se_configs(self, stack): + helper = self._new_helper() + helper._parse_raw_stacks([stack]) + rows = helper.get_stacks_as_table(runtime_filter='java', support_filter=None) + return {r['config'] for r in rows if r['runtime'] == 'Java'} + + def test_aggregate_runtimes_array_complete(self): + # Primary path: the aggregate auto-update container carries the full + # 'runtimes' array via the typespec Mapping interface. + aggregate = self._minor('SE', _TypespecContainerSettings( + {'isAutoUpdate': True, 'runtimes': self.FULL_RUNTIMES}, is_auto_update=True)) + stack = self._java_se_stack([aggregate] + self._patch_minors()) + self.assertEqual(self._java_se_configs(stack), self.EXPECTED) + + def test_aggregate_javaNNRuntime_keys(self): + # Fallback path: no 'runtimes' array, but the Mapping exposes individual + # javaNNRuntime camelCase keys for every available major version. + aggregate = self._minor('SE', _TypespecContainerSettings( + { + 'isAutoUpdate': True, + 'java8Runtime': 'JAVA|8-jre8', + 'java11Runtime': 'JAVA|11-java11', + 'java17Runtime': 'JAVA|17-java17', + 'java21Runtime': 'JAVA|21-java21', + 'java25Runtime': 'JAVA|25-java25', + }, + is_auto_update=True)) + stack = self._java_se_stack([aggregate] + self._patch_minors()) + self.assertEqual(self._java_se_configs(stack), self.EXPECTED) + + def test_aggregate_not_first_selected_by_auto_update(self): + # The aggregate auto-update container must be chosen by its is_auto_update + # flag, not its position -- here it is returned last, after the per-patch minors. + aggregate = self._minor('SE', _TypespecContainerSettings( + {'isAutoUpdate': True, 'runtimes': self.FULL_RUNTIMES}, is_auto_update=True)) + stack = self._java_se_stack(self._patch_minors() + [aggregate]) + self.assertEqual(self._java_se_configs(stack), self.EXPECTED) + + def test_typed_attrs_only_expose_java_8_11_but_mapping_has_all(self): + # Reproduces the exact regression: the SDK types only java8_runtime / + # java11_runtime, and additional_properties is empty, but the full data is + # available through the Mapping. The fix must read the Mapping, not just the + # typed attributes, otherwise Java 17/21/25 are silently dropped. + aggregate = self._minor('SE', _TypespecContainerSettings( + {'isAutoUpdate': True, 'runtimes': self.FULL_RUNTIMES}, + java8='JAVA|8-jre8', java11='JAVA|11-java11', is_auto_update=True)) + # Sanity-check the model: typed attrs cover only 8/11, additional_properties empty. + self.assertEqual(aggregate.stack_settings.linux_container_settings.additional_properties, []) + stack = self._java_se_stack([aggregate] + self._patch_minors()) + self.assertEqual(self._java_se_configs(stack), self.EXPECTED) + + def test_runtimes_array_entries_flagged_auto_update(self): + # Entries derived from the aggregate must be flagged auto-update so they + # survive the table filter that drops non-auto-update java rows. + aggregate = self._minor('SE', _TypespecContainerSettings( + {'isAutoUpdate': True, 'runtimes': self.FULL_RUNTIMES}, is_auto_update=True)) + stack = self._java_se_stack([aggregate] + self._patch_minors()) + helper = self._new_helper() + helper._parse_raw_stacks([stack]) + java_runtimes = [s for s in helper._stacks if s.display_name in self.EXPECTED] + self.assertEqual({s.display_name for s in java_runtimes}, self.EXPECTED) + self.assertTrue(all(s.is_auto_update for s in java_runtimes)) + + def test_get_container_settings_data_reads_mapping(self): + from azure.cli.command_modules.appservice.custom import _StackRuntimeHelper + settings = _TypespecContainerSettings( + {'isAutoUpdate': True, 'java25Runtime': 'JAVA|25-java25', 'runtimes': self.FULL_RUNTIMES}, + java8='JAVA|8-jre8', java11='JAVA|11-java11', is_auto_update=True) + data = _StackRuntimeHelper._get_container_settings_data(settings) + self.assertTrue(data.get('isAutoUpdate')) + self.assertEqual(data.get('java25Runtime'), 'JAVA|25-java25') + self.assertEqual(len(data.get('runtimes')), 5) + + def test_get_java_runtimes_from_container_settings_reads_mapping(self): + from azure.cli.command_modules.appservice.custom import _StackRuntimeHelper + settings = _TypespecContainerSettings( + {'isAutoUpdate': True, 'runtimes': self.FULL_RUNTIMES}, + java8='JAVA|8-jre8', java11='JAVA|11-java11', is_auto_update=True) + runtimes = _StackRuntimeHelper._get_java_runtimes_from_container_settings(settings) + self.assertEqual({name for name, _, _ in runtimes}, self.EXPECTED) + self.assertTrue(all(is_auto for _, _, is_auto in runtimes)) + + if __name__ == '__main__': - unittest.main() + unittest.main() \ No newline at end of file From 4512b9f4473b97cd38f452205a5d90aae5684c58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:55:29 +0000 Subject: [PATCH 10/12] fix: fully restore appservice test file to remove all out-of-scope changes Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- .../latest/test_webapp_commands_thru_mock.py | 401 +++++++++++++++++- 1 file changed, 398 insertions(+), 3 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 697496f307c..7cb3d748f72 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -16,7 +16,8 @@ MutuallyExclusiveArgumentError, ArgumentUsageError, AzureResponseError, - ResourceNotFoundError) + ResourceNotFoundError, + ValidationError) from azure.cli.command_modules.appservice.custom import (set_deployment_user, update_git_token, add_hostname, update_site_configs, @@ -42,6 +43,7 @@ show_startup_log, troubleshoot_status, create_webapp) +from azure.cli.command_modules.appservice._deployment_context_engine import EnrichedDeploymentError # pylint: disable=line-too-long from azure.cli.core.profiles import ResourceType @@ -73,7 +75,7 @@ def setUp(self): @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') @mock.patch('azure.cli.command_modules.appservice.custom.get_app_details') def test_webapp_github_actions_add(self, get_app_details_mock, web_client_factory_mock, site_availability_mock, *args): - runtime = "python:3.9" + runtime = "NODE:26-lts" rg = "group" is_linux = True cmd = _get_test_cmd() @@ -84,7 +86,7 @@ def test_webapp_github_actions_add(self, get_app_details_mock, web_client_factor with mock.patch('azure.cli.command_modules.appservice.custom._runtime_supports_github_actions', autospec=True) as m: add_github_actions(cmd, rg, "name", "repo", runtime, "token") - m.assert_called_with(cmd, runtime.replace(":", "|"), is_linux) + m.assert_called_with(cmd=cmd, runtime_string="NODE|26", is_linux=is_linux) @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) def test_set_deployment_user_creds(self, client_factory_mock): @@ -232,6 +234,99 @@ def test_update_site_config(self, is_centauri_functionapp_mock, site_op_mock): self.assertEqual(site_config.use32_bit_worker_process, None) self.assertEqual(site_config.java_container, None) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp', autospec=True) + def test_update_site_config_generic_configurations_camelcase(self, is_centauri_functionapp_mock, site_op_mock): + """Verify that camelCase key=value properties in --generic-configurations (e.g. webJobsEnabled=false) + are serialized under properties.webJobsEnabled (not at resource root) and that the string 'false' + is coerced to boolean False (GitHub issue #33823).""" + cmd_mock = _get_test_cmd() + SiteConfigResource = cmd_mock.get_models('SiteConfigResource') + SiteConfig = cmd_mock.get_models('SiteConfig') + site_config_resource = SiteConfigResource() + site_config_resource.properties = SiteConfig() + site_op_mock.return_value = site_config_resource + + is_centauri_functionapp_mock.return_value = False + # action: pass webJobsEnabled (camelCase, not a named SDK property) via generic_configurations + update_site_configs(cmd_mock, 'myRG', 'myweb', + generic_configurations=['webJobsEnabled=false']) + # assert: the property must be on properties (the SiteConfig child), not at resource root + self.assertNotIn('webJobsEnabled', dict(site_config_resource)) + self.assertIn('webJobsEnabled', dict(site_config_resource.properties)) + # assert: string 'false' from key=value form must be coerced to boolean False + self.assertIs(site_config_resource.properties['webJobsEnabled'], False) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp', autospec=True) + def test_update_site_config_generic_configurations_camelcase_json(self, is_centauri_functionapp_mock, site_op_mock): + """Verify that camelCase properties in --generic-configurations provided as JSON + (e.g. {"webJobsEnabled": false}) are serialized under properties.webJobsEnabled.""" + cmd_mock = _get_test_cmd() + SiteConfigResource = cmd_mock.get_models('SiteConfigResource') + SiteConfig = cmd_mock.get_models('SiteConfig') + site_config_resource = SiteConfigResource() + site_config_resource.properties = SiteConfig() + site_op_mock.return_value = site_config_resource + + is_centauri_functionapp_mock.return_value = False + # action: pass webJobsEnabled as a JSON object + update_site_configs(cmd_mock, 'myRG', 'myweb', + generic_configurations=['{"webJobsEnabled": false}']) + # assert: property must be on the SiteConfig child (properties), not at resource root + self.assertNotIn('webJobsEnabled', dict(site_config_resource)) + self.assertIn('webJobsEnabled', dict(site_config_resource.properties)) + self.assertIs(site_config_resource.properties['webJobsEnabled'], False) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp', autospec=True) + def test_update_site_config_generic_configurations_request_body_serialization( + self, is_centauri_functionapp_mock, site_op_mock): + """Regression test: the serialized ARM request body must contain webJobsEnabled under + 'properties', never at the resource root. A root-level webJobsEnabled is silently + ignored by ARM, leaving properties.webJobsEnabled unchanged (the live bug).""" + import json + from azure.mgmt.web._utils.model_base import SdkJSONEncoder + + cmd_mock = _get_test_cmd() + SiteConfigResource = cmd_mock.get_models('SiteConfigResource') + SiteConfig = cmd_mock.get_models('SiteConfig') + site_config_resource = SiteConfigResource() + site_config_resource.properties = SiteConfig() + site_config_resource.properties.always_on = True # existing setting + site_op_mock.return_value = site_config_resource + + is_centauri_functionapp_mock.return_value = False + + # Test key=value form + update_site_configs(cmd_mock, 'myRG', 'myweb', + generic_configurations=['webJobsEnabled=false']) + + body = json.loads(json.dumps(site_config_resource, cls=SdkJSONEncoder, exclude_readonly=True)) + # webJobsEnabled must NOT appear at the resource root + self.assertNotIn('webJobsEnabled', body, + "webJobsEnabled must not be at resource root; ARM ignores root-level props") + # webJobsEnabled must appear under properties + self.assertIn('properties', body) + self.assertIn('webJobsEnabled', body['properties']) + self.assertIs(body['properties']['webJobsEnabled'], False) + + # Test JSON-file form + site_config_resource2 = SiteConfigResource() + site_config_resource2.properties = SiteConfig() + site_config_resource2.properties.always_on = True + site_op_mock.return_value = site_config_resource2 + + update_site_configs(cmd_mock, 'myRG', 'myweb', + generic_configurations=['{"webJobsEnabled": false}']) + + body2 = json.loads(json.dumps(site_config_resource2, cls=SdkJSONEncoder, exclude_readonly=True)) + self.assertNotIn('webJobsEnabled', body2, + "webJobsEnabled must not be at resource root; ARM ignores root-level props") + self.assertIn('properties', body2) + self.assertIn('webJobsEnabled', body2['properties']) + self.assertIs(body2['properties']['webJobsEnabled'], False) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation', autospec=True) def test_list_publish_profiles_on_slots(self, site_op_mock): site_op_mock.return_value = [b''] @@ -473,6 +568,110 @@ def test_linux_webapp_create_no_runtime_raises_error(self, get_site_avail_mock, self.assertIn('--runtime', str(context.exception)) self.assertIn('--os-type linux', str(context.exception)) + @staticmethod + def _configure_webapp_create_failure(get_site_avail_mock, stack_helper_mock, web_client_mock, + is_linux, error): + cmd_mock = _get_test_cmd() + SiteConfig, SkuDescription, NameValuePair = cmd_mock.get_models( + 'SiteConfig', 'SkuDescription', 'NameValuePair') + cmd_mock.get_models = mock.MagicMock(return_value=(SiteConfig, SkuDescription, NameValuePair)) + + plan_info = mock.MagicMock() + plan_info.name = 'test-plan' + plan_info.reserved = is_linux + plan_info.is_xenon = False + plan_info.location = 'westus2' + plan_info.id = '/subscriptions/sub/resourceGroups/test-rg/providers/Microsoft.Web/serverfarms/test-plan' + plan_info.sku = SkuDescription(name='B1') + web_client_mock.return_value.app_service_plans.get.return_value = plan_info + web_client_mock.return_value.web_apps.begin_create_or_update.side_effect = error + + name_validation = mock.MagicMock() + name_validation.name_available = True + get_site_avail_mock.return_value = name_validation + stack_helper_mock.return_value.get_default_version.return_value = '20.0' + return cmd_mock + + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom._StackRuntimeHelper', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.get_site_availability', autospec=True) + def test_linux_webapp_create_enriches_control_plane_failure(self, get_site_avail_mock, + stack_helper_mock, web_client_mock): + error = RuntimeError('Status Code: 400 Linux workers are unavailable due to capacity') + cmd_mock = self._configure_webapp_create_failure( + get_site_avail_mock, stack_helper_mock, web_client_mock, True, error) + + with self.assertRaises(EnrichedDeploymentError) as context: + create_webapp(cmd_mock, 'test-rg', 'test-app', 'test-plan', + container_image_name='nginx:latest', enriched_errors=True) + + self.assertIn('WEB APP CREATION FAILED', str(context.exception)) + self.assertIn('LinuxWorkersUnavailable', str(context.exception)) + self.assertIn('Runtime : DOCKER|nginx:latest', str(context.exception)) + + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom._StackRuntimeHelper', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.get_site_availability', autospec=True) + def test_linux_webapp_create_enriches_invalid_runtime(self, get_site_avail_mock, + stack_helper_mock, web_client_mock): + cmd_mock = self._configure_webapp_create_failure( + get_site_avail_mock, stack_helper_mock, web_client_mock, True, None) + stack_helper_mock.remove_delimiters.return_value = 'PYTHON|99.99' + stack_helper_mock.return_value.resolve.return_value = None + + with self.assertRaises(EnrichedDeploymentError) as context: + create_webapp(cmd_mock, 'test-rg', 'test-app', 'test-plan', + runtime='PYTHON:99.99', enriched_errors=True) + + self.assertIn('InvalidLinuxRuntime', str(context.exception)) + self.assertIn('Runtime : PYTHON|99.99', str(context.exception)) + self.assertIn('az webapp list-runtimes --os-type linux -o table', str(context.exception)) + + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom._StackRuntimeHelper', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.get_site_availability', autospec=True) + def test_linux_webapp_create_preserves_invalid_runtime_when_enrichment_disabled( + self, get_site_avail_mock, stack_helper_mock, web_client_mock): + cmd_mock = self._configure_webapp_create_failure( + get_site_avail_mock, stack_helper_mock, web_client_mock, True, None) + stack_helper_mock.remove_delimiters.return_value = 'PYTHON|99.99' + stack_helper_mock.return_value.resolve.return_value = None + + with self.assertRaises(ValidationError) as context: + create_webapp(cmd_mock, 'test-rg', 'test-app', 'test-plan', + runtime='PYTHON:99.99', enriched_errors=False) + + self.assertIn("Linux Runtime 'PYTHON|99.99' is not supported", str(context.exception)) + + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom._StackRuntimeHelper', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.get_site_availability', autospec=True) + def test_linux_webapp_create_preserves_failure_when_enrichment_disabled(self, get_site_avail_mock, + stack_helper_mock, web_client_mock): + error = RuntimeError('Status Code: 400 Linux workers are unavailable due to capacity') + cmd_mock = self._configure_webapp_create_failure( + get_site_avail_mock, stack_helper_mock, web_client_mock, True, error) + + with self.assertRaises(RuntimeError) as context: + create_webapp(cmd_mock, 'test-rg', 'test-app', 'test-plan', + container_image_name='nginx:latest', enriched_errors=False) + + self.assertIs(context.exception, error) + + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom._StackRuntimeHelper', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.get_site_availability', autospec=True) + def test_windows_webapp_create_preserves_failure_when_enrichment_enabled(self, get_site_avail_mock, + stack_helper_mock, web_client_mock): + error = RuntimeError('Status Code: 400 bad request') + cmd_mock = self._configure_webapp_create_failure( + get_site_avail_mock, stack_helper_mock, web_client_mock, False, error) + + with self.assertRaises(RuntimeError) as context: + create_webapp(cmd_mock, 'test-rg', 'test-app', 'test-plan', enriched_errors=True) + + self.assertIs(context.exception, error) + @mock.patch('azure.cli.command_modules.appservice.custom.is_flex_functionapp', autospec=True) @mock.patch('azure.cli.command_modules.appservice.custom._verify_hostname_binding', autospec=True) @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) @@ -1412,6 +1611,46 @@ def __init__(self, status_code): self.status_code = status_code +class TestOneDeployTag(unittest.TestCase): + + def test_scm_url_includes_encoded_tag(self): + from azure.cli.command_modules.appservice.custom import OneDeployParams, _build_onedeploy_scm_url + params = OneDeployParams() + params.artifact_type = 'zip' + params.tag = 'release 2026/08' + + with mock.patch('azure.cli.command_modules.appservice.custom._get_or_fetch_scm_url', + return_value='https://example.scm.azurewebsites.net'): + result = _build_onedeploy_scm_url(params) + + self.assertEqual(result, 'https://example.scm.azurewebsites.net/api/publish?type=zip&tag=release%202026%2F08') + + @mock.patch('azure.cli.command_modules.appservice.custom._perform_onedeploy_internal') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_webapp_deploy_ignores_tag_for_windows_webapp(self, site_operation_mock, perform_deploy_mock): + from azure.cli.command_modules.appservice.custom import perform_onedeploy_webapp + site_operation_mock.return_value = mock.MagicMock(kind='app', reserved=False) + + with mock.patch('azure.cli.command_modules.appservice.custom.logger.warning') as warning_mock: + perform_onedeploy_webapp(mock.MagicMock(), 'myRG', 'myApp', tag='windows-tag') + + warning_mock.assert_any_call('--tag is only supported for Linux web apps and will be ignored.') + self.assertIsNone(perform_deploy_mock.call_args.args[0].tag) + + def test_arm_body_includes_tag(self): + import json + from azure.cli.command_modules.appservice.custom import OneDeployParams, _get_onedeploy_request_body + params = OneDeployParams() + params.src_url = 'https://example.com/app.zip' + params.artifact_type = 'zip' + params.tag = 'release-2026-08' + + body, file_hash = _get_onedeploy_request_body(params) + + self.assertEqual(json.loads(body)['properties']['tag'], 'release-2026-08') + self.assertIsNone(file_hash) + + class TestCreateAppServicePlanDefaults(unittest.TestCase): """Tests for create_app_service_plan default SKU behavior""" @@ -1438,6 +1677,74 @@ def test_default_sku_is_p0v3_when_not_specified(self, mock_location, mock_client # The sku name should be normalized P0V3 self.assertIn('P0V3', str(call_kwargs)) + def test_update_to_isolated_v4_sku_requires_ase(self): + from azure.cli.command_modules.appservice.custom import update_app_service_plan + instance = mock.MagicMock() + instance.hosting_environment_profile = None + instance.zone_redundant = False + + with self.assertRaises(ValidationError): + update_app_service_plan(mock.MagicMock(), instance, sku='I1V4') + + @mock.patch('azure.cli.command_modules.appservice.custom._enable_managed_instance_properties') + def test_update_to_isolated_v4_sku_on_ase(self, _): + from azure.cli.command_modules.appservice.custom import update_app_service_plan + instance = mock.MagicMock() + instance.hosting_environment_profile = mock.MagicMock() + instance.zone_redundant = False + instance.sku.capacity = 1 + + result = update_app_service_plan(mock.MagicMock(), instance, sku='I1MV4') + + self.assertEqual(result.sku.name, 'I1MV4') + self.assertEqual(result.sku.tier, 'IsolatedV4') + + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom._get_location_from_resource_group', return_value='eastus') + def test_is_linux_none_defaults_to_linux(self, mock_location, mock_client_factory): + """When is_linux is omitted (None) and hyper_v is False, plan defaults to Linux (reserved=True). + This matches the documented default: '--is-linux' defaults to true unless --hyper-v is specified. + Windows plans must explicitly pass --is-linux false.""" + from azure.cli.command_modules.appservice.custom import create_app_service_plan + mock_cmd = mock.MagicMock() + mock_app_service_plan_cls = mock.MagicMock() + mock_cmd.get_models.return_value = (mock.MagicMock(), mock.MagicMock(), mock_app_service_plan_cls) + mock_cmd.cli_ctx = mock.MagicMock() + mock_client = mock.MagicMock() + mock_client_factory.return_value = mock_client + + try: + create_app_service_plan(mock_cmd, 'rg', 'plan', is_linux=None, hyper_v=False) + except Exception: + pass + + # AppServicePlan should be constructed with reserved=True (Linux) + mock_app_service_plan_cls.assert_called() + call_kwargs = mock_app_service_plan_cls.call_args + self.assertIn('reserved=True', str(call_kwargs)) + + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom._get_location_from_resource_group', return_value='eastus') + def test_is_linux_false_creates_windows_plan(self, mock_location, mock_client_factory): + """When is_linux=False is explicitly passed, plan is created as Windows (reserved=False).""" + from azure.cli.command_modules.appservice.custom import create_app_service_plan + mock_cmd = mock.MagicMock() + mock_app_service_plan_cls = mock.MagicMock() + mock_cmd.get_models.return_value = (mock.MagicMock(), mock.MagicMock(), mock_app_service_plan_cls) + mock_cmd.cli_ctx = mock.MagicMock() + mock_client = mock.MagicMock() + mock_client_factory.return_value = mock_client + + try: + create_app_service_plan(mock_cmd, 'rg', 'plan', is_linux=False, hyper_v=False) + except Exception: + pass + + # AppServicePlan should be constructed with reserved=False (Windows) + mock_app_service_plan_cls.assert_called() + call_kwargs = mock_app_service_plan_cls.call_args + self.assertIn('reserved=False', str(call_kwargs)) + class TestOneDeployScmCache(unittest.TestCase): """Tests for the per-invocation SCM URL / SCM headers cache on OneDeployParams. @@ -2012,6 +2319,94 @@ def __len__(self): return len(self._data) +class TestStackRuntimeNodeStandardization(unittest.TestCase): + @staticmethod + def _new_helper(): + from azure.cli.command_modules.appservice.custom import _StackRuntimeHelper + helper = _StackRuntimeHelper.__new__(_StackRuntimeHelper) + helper._linux = True + helper._windows = True + helper._include_eol = False + helper._stacks = [] + helper.windows_config_mappings = {'node': 'WEBSITE_NODE_DEFAULT_VERSION'} + return helper + + @staticmethod + def _node_stack(version, display_text, linux_runtime): + git_hub_action_settings = types.SimpleNamespace(is_supported=True, supported_version="{}.x".format(version)) + linux_settings = types.SimpleNamespace( + runtime_version=linux_runtime, + is_hidden=False, + is_deprecated=False, + end_of_life_date=None, + git_hub_action_settings=git_hub_action_settings, + ) + windows_settings = types.SimpleNamespace( + runtime_version="~{}".format(version), + is_hidden=False, + is_deprecated=False, + end_of_life_date=None, + git_hub_action_settings=git_hub_action_settings, + ) + minor = types.SimpleNamespace( + display_text=display_text, + stack_settings=types.SimpleNamespace( + linux_container_settings=None, + linux_runtime_settings=linux_settings, + windows_container_settings=None, + windows_runtime_settings=windows_settings, + ), + ) + major = types.SimpleNamespace(display_text=display_text, minor_versions=[minor]) + return types.SimpleNamespace(display_text='Node', major_versions=[major]) + + def test_node_26_uses_standard_identifier_on_both_platforms(self): + from azure.cli.command_modules.appservice.custom import _get_app_runtime_info_helper + + helper = self._new_helper() + helper._parse_raw_stacks([self._node_stack('26', 'Node 26 LTS', 'NODE|26-lts')]) + + self.assertEqual( + [(runtime.os, runtime.display_name) for runtime in helper._stacks], + [('Linux', 'NODE|26'), ('Windows', 'NODE|26')]) + self.assertEqual( + [(row['os'], row['config']) for row in helper.get_stacks_as_table(runtime_filter='node')], + [('Linux', 'NODE|26'), ('Windows', 'NODE|26')]) + self.assertEqual(helper.resolve('NODE|26', linux=True).configs['linux_fx_version'], 'NODE|26') + self.assertEqual( + helper.resolve('NODE|26', linux=False).configs['WEBSITE_NODE_DEFAULT_VERSION'], '~26') + self.assertEqual(helper.resolve('NODE|26-lts', linux=True).configs['linux_fx_version'], 'NODE|26') + self.assertEqual( + helper.resolve('NODE|26LTS', linux=False).configs['WEBSITE_NODE_DEFAULT_VERSION'], '~26') + + with mock.patch('azure.cli.command_modules.appservice.custom._StackRuntimeHelper', return_value=helper): + runtime_info = _get_app_runtime_info_helper(mock.Mock(), 'NODE|26-lts', '', True) + + self.assertEqual(runtime_info, { + 'display_name': 'NODE|26', + 'github_actions_version': '26.x', + }) + + def test_older_node_identifiers_remain_unchanged(self): + helper = self._new_helper() + helper._parse_raw_stacks([self._node_stack('24', 'Node 24 LTS', 'NODE|24-lts')]) + + self.assertEqual( + [(runtime.os, runtime.display_name) for runtime in helper._stacks], + [('Linux', 'NODE|24-lts'), ('Windows', 'NODE|24LTS')]) + + def test_node_26_transition_identifiers_are_deduplicated(self): + helper = self._new_helper() + helper._parse_raw_stacks([ + self._node_stack('26', 'Node 26 LTS', 'NODE|26-lts'), + self._node_stack('26', 'Node 26', 'NODE|26'), + ]) + + self.assertEqual( + [(runtime.os, runtime.display_name) for runtime in helper._stacks], + [('Linux', 'NODE|26'), ('Windows', 'NODE|26')]) + + class TestStackRuntimeJavaSELinux(unittest.TestCase): """Regression tests for `az webapp list-runtimes` Linux Java SE parsing. From 151da3b82ced1d99a5f0c883148b714a2d883723 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:07:47 +0000 Subject: [PATCH 11/12] fix: correct namespace_name pattern in eventhubs eventhub list schema to match 6-50 char constraint Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- .../eventhubs/aaz/latest/eventhubs/eventhub/_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli/azure/cli/command_modules/eventhubs/aaz/latest/eventhubs/eventhub/_list.py b/src/azure-cli/azure/cli/command_modules/eventhubs/aaz/latest/eventhubs/eventhub/_list.py index 1fe1c9a6df0..22b9cffe312 100644 --- a/src/azure-cli/azure/cli/command_modules/eventhubs/aaz/latest/eventhubs/eventhub/_list.py +++ b/src/azure-cli/azure/cli/command_modules/eventhubs/aaz/latest/eventhubs/eventhub/_list.py @@ -47,7 +47,7 @@ def _build_arguments_schema(cls, *args, **kwargs): help="The Namespace name", required=True, fmt=AAZStrArgFormat( - pattern="^[a-zA-Z][a-zA-Z0-9-]{6,50}[a-zA-Z0-9]$", + pattern="^[a-zA-Z][a-zA-Z0-9-]{4,48}[a-zA-Z0-9]$", max_length=50, min_length=6, ), From c9eb39436e06d8a6852508ce4cc0274e3e101d1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:40:19 +0000 Subject: [PATCH 12/12] fix: move namespace_name validation out of generated aaz file into _validator.py Revert the direct change to the generated aaz/_list.py (which requires a linked Azure/aaz upstream PR), and instead apply the namespace_name pattern validation through the non-generated _validator.py / _params.py path. - Revert aaz/latest/eventhubs/eventhub/_list.py to its original state - Add validate_namespace_name() to _validator.py with the correct 6-50 char pattern (^[a-zA-Z][a-zA-Z0-9-]{4,48}[a-zA-Z0-9]$) - Register the validator on 'eventhubs eventhub list' in _params.py - Update tests to exercise the validator function directly Co-authored-by: a0x1ab <59631311+a0x1ab@users.noreply.github.com> --- .../cli/command_modules/eventhubs/_params.py | 5 +- .../command_modules/eventhubs/_validator.py | 10 ++++ .../aaz/latest/eventhubs/eventhub/_list.py | 2 +- .../test_eventhub_command_validation.py | 54 +++++++++++-------- 4 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/eventhubs/_params.py b/src/azure-cli/azure/cli/command_modules/eventhubs/_params.py index 77a59224b49..21ee108c25a 100644 --- a/src/azure-cli/azure/cli/command_modules/eventhubs/_params.py +++ b/src/azure-cli/azure/cli/command_modules/eventhubs/_params.py @@ -13,7 +13,7 @@ def load_arguments_eh(self, _): get_location_type, get_three_state_flag, get_resource_name_completion_list from azure.cli.core.commands.validators import get_default_location_from_resource_group from azure.cli.command_modules.eventhubs._completers import get_eventhubs_command_completion_list - from azure.cli.command_modules.eventhubs._validator import validate_storageaccount, validate_partner_namespace + from azure.cli.command_modules.eventhubs._validator import validate_storageaccount, validate_partner_namespace, validate_namespace_name from knack.arguments import CLIArgumentType from azure.cli.core.profiles import ResourceType (SkuName, TlsVersion) = self.get_models('SkuName', 'TlsVersion', resource_type=ResourceType.MGMT_EVENTHUB) @@ -88,7 +88,8 @@ def load_arguments_eh(self, _): c.argument('min_compaction_lag_in_mins', type=int, arg_group='Retention-Description', options_list=['--min-lag', '--min-compaction-lag-in-mins'], help="The minimum time a message will remain ineligible for compaction in the log. This value is used when cleanupPolicy is Compact or DeleteOrCompact.") c.argument('encoding', arg_group='Capture', options_list=['encoding'], help='Enumerates the possible values for the encoding format of capture description. Note: \'AvroDeflate\' will be deprecated in New API Version') with self.argument_context('eventhubs eventhub list') as c: - c.argument('namespace_name', options_list=['--namespace-name'], id_part=None, help='Name of Namespace') + c.argument('namespace_name', options_list=['--namespace-name'], id_part=None, help='Name of Namespace', + validator=validate_namespace_name) # Region Geo DR Configuration with self.argument_context('eventhubs georecovery-alias set') as c: diff --git a/src/azure-cli/azure/cli/command_modules/eventhubs/_validator.py b/src/azure-cli/azure/cli/command_modules/eventhubs/_validator.py index 93457c03bad..61965b46888 100644 --- a/src/azure-cli/azure/cli/command_modules/eventhubs/_validator.py +++ b/src/azure-cli/azure/cli/command_modules/eventhubs/_validator.py @@ -9,6 +9,16 @@ from azure.cli.core.util import CLIError +def validate_namespace_name(namespace): + import re + name = namespace.namespace_name + if name and not re.fullmatch(r'^[a-zA-Z][a-zA-Z0-9-]{4,48}[a-zA-Z0-9]$', name): + raise CLIError( + 'Invalid namespace name "{}". The namespace name must be 6-50 characters, start with a letter, ' + 'end with a letter or digit, and contain only letters, digits, and hyphens.'.format(name) + ) + + def validate_storageaccount(cmd, namespace): from azure.cli.core.commands.client_factory import get_subscription_id from azure.mgmt.core.tools import is_valid_resource_id, resource_id diff --git a/src/azure-cli/azure/cli/command_modules/eventhubs/aaz/latest/eventhubs/eventhub/_list.py b/src/azure-cli/azure/cli/command_modules/eventhubs/aaz/latest/eventhubs/eventhub/_list.py index 22b9cffe312..1fe1c9a6df0 100644 --- a/src/azure-cli/azure/cli/command_modules/eventhubs/aaz/latest/eventhubs/eventhub/_list.py +++ b/src/azure-cli/azure/cli/command_modules/eventhubs/aaz/latest/eventhubs/eventhub/_list.py @@ -47,7 +47,7 @@ def _build_arguments_schema(cls, *args, **kwargs): help="The Namespace name", required=True, fmt=AAZStrArgFormat( - pattern="^[a-zA-Z][a-zA-Z0-9-]{4,48}[a-zA-Z0-9]$", + pattern="^[a-zA-Z][a-zA-Z0-9-]{6,50}[a-zA-Z0-9]$", max_length=50, min_length=6, ), diff --git a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py index a60b44dc2cb..2fc9cf082f1 100644 --- a/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py +++ b/src/azure-cli/azure/cli/command_modules/eventhubs/tests/latest/test_eventhub_command_validation.py @@ -3,29 +3,41 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import re import unittest +from unittest.mock import MagicMock +from azure.cli.core.util import CLIError class EventHubCommandValidationTests(unittest.TestCase): - def test_eventhub_list_namespace_name_length_constraints(self): - from azure.cli.command_modules.eventhubs.aaz.latest.eventhubs.eventhub._list import List - - namespace_name_format = List._build_arguments_schema().namespace_name._fmt - valid_min_length_namespace = 'ns0001' - valid_short_namespace = 'ns00001' - invalid_under_min_length_namespace = 'ns001' - valid_max_length_namespace = 'n' + ('0' * 48) + '1' - invalid_over_max_length_namespace = 'n' + ('0' * 49) + '1' - - self.assertEqual(namespace_name_format._pattern, "^[a-zA-Z][a-zA-Z0-9-]{4,48}[a-zA-Z0-9]$") - self.assertEqual(namespace_name_format._min_length, 6) - self.assertEqual(namespace_name_format._max_length, 50) - self.assertEqual(len(valid_min_length_namespace), namespace_name_format._min_length) - self.assertEqual(len(valid_max_length_namespace), namespace_name_format._max_length) - self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, valid_min_length_namespace)) - self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, valid_short_namespace)) - self.assertIsNotNone(re.fullmatch(namespace_name_format._pattern, valid_max_length_namespace)) - self.assertIsNone(re.fullmatch(namespace_name_format._pattern, invalid_under_min_length_namespace)) - self.assertIsNone(re.fullmatch(namespace_name_format._pattern, invalid_over_max_length_namespace)) + def _make_namespace(self, name): + ns = MagicMock() + ns.namespace_name = name + return ns + + def test_eventhub_list_namespace_name_valid_min_length(self): + from azure.cli.command_modules.eventhubs._validator import validate_namespace_name + validate_namespace_name(self._make_namespace('ns0001')) + + def test_eventhub_list_namespace_name_valid_max_length(self): + from azure.cli.command_modules.eventhubs._validator import validate_namespace_name + validate_namespace_name(self._make_namespace('n' + '0' * 48 + '1')) + + def test_eventhub_list_namespace_name_valid_short(self): + from azure.cli.command_modules.eventhubs._validator import validate_namespace_name + validate_namespace_name(self._make_namespace('ns00001')) + + def test_eventhub_list_namespace_name_too_short(self): + from azure.cli.command_modules.eventhubs._validator import validate_namespace_name + with self.assertRaises(CLIError): + validate_namespace_name(self._make_namespace('ns001')) + + def test_eventhub_list_namespace_name_too_long(self): + from azure.cli.command_modules.eventhubs._validator import validate_namespace_name + with self.assertRaises(CLIError): + validate_namespace_name(self._make_namespace('n' + '0' * 49 + '1')) + + def test_eventhub_list_namespace_name_none_passes(self): + from azure.cli.command_modules.eventhubs._validator import validate_namespace_name + validate_namespace_name(self._make_namespace(None)) +