Skip to content

Commit f1c087e

Browse files
[App Service] Fix #32290: az functionapp config appsettings set: Fix command failure when using --slot-settings parameter to update existing slot settings (#32291)
1 parent 9fbcb2f commit f1c087e

File tree

2 files changed

+215
-39
lines changed

2 files changed

+215
-39
lines changed

src/azure-cli/azure/cli/command_modules/appservice/custom.py

Lines changed: 74 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -519,60 +519,87 @@ def update_app_settings_functionapp(cmd, resource_group_name, name, settings=Non
519519
return update_app_settings(cmd, resource_group_name, name, settings, slot, slot_settings)
520520

521521

522+
def _parse_json_setting(s, result, slot_result, setting_type):
523+
"""
524+
Parse JSON format settings.
525+
526+
Parameters:
527+
s (str): The input string containing JSON-formatted settings.
528+
result (dict): A dictionary to store the parsed key-value pairs from the settings.
529+
slot_result (dict): A dictionary to store slot setting flags for each key.
530+
setting_type (str): The type of settings being parsed, either "SlotSettings" or "Settings".
531+
532+
Returns:
533+
bool: True if parsing was successful, False otherwise.
534+
"""
535+
try:
536+
temp = shell_safe_json_parse(s)
537+
if isinstance(temp, list): # Accept the output of the "list" command
538+
for t in temp:
539+
if 'slotSetting' in t.keys():
540+
slot_result[t['name']] = t['slotSetting']
541+
elif setting_type == "SlotSettings":
542+
slot_result[t['name']] = True
543+
result[t['name']] = t['value']
544+
else:
545+
# Handle JSON objects: setting_type is either "SlotSettings" or "Settings"
546+
# Different logic needed for slot settings vs regular settings
547+
if setting_type == "SlotSettings":
548+
# For slot settings JSON objects, add values to result and mark as slot settings
549+
result.update(temp)
550+
for key in temp:
551+
slot_result[key] = True
552+
else:
553+
# For regular settings JSON objects, add values to result only
554+
result.update(temp)
555+
return True
556+
except InvalidArgumentValueError:
557+
return False
558+
559+
522560
def update_app_settings(cmd, resource_group_name, name, settings=None, slot=None, slot_settings=None):
523561
if not settings and not slot_settings:
524-
raise MutuallyExclusiveArgumentError('Usage Error: --settings |--slot-settings')
562+
raise MutuallyExclusiveArgumentError('Please provide either --settings or --slot-settings parameter.')
525563

526564
settings = settings or []
527565
slot_settings = slot_settings or []
528566

529567
app_settings = _generic_site_operation(cmd.cli_ctx, resource_group_name, name,
530568
'list_application_settings', slot)
531569
result, slot_result = {}, {}
532-
# pylint: disable=too-many-nested-blocks
533-
for src, dest, setting_type in [(settings, result, "Settings"), (slot_settings, slot_result, "SlotSettings")]:
570+
571+
for src, setting_type in [(settings, "Settings"), (slot_settings, "SlotSettings")]:
534572
for s in src:
535-
# Check if this looks like a simple key=value pair without JSON/dict syntax
536-
# If so, parse it directly to avoid unnecessary warnings from ast.literal_eval
537-
if ('=' in s and not s.lstrip().startswith(('{"', "[", "{")) and
538-
not s.startswith('@')): # @ indicates file input
539-
try:
540-
setting_name, value = s.split('=', 1)
541-
dest[setting_name] = value
542-
continue
543-
except ValueError:
544-
pass # Fall back to JSON parsing if split fails
573+
# Try simple key=value parsing first
574+
if '=' in s and not s.lstrip().startswith(('{"', "[", "{")) and not s.startswith('@'):
575+
k, v = s.split('=', 1)
576+
result[k] = v
577+
if setting_type == "SlotSettings":
578+
slot_result[k] = True
579+
continue
545580

581+
# Try JSON parsing
582+
if _parse_json_setting(s, result, slot_result, setting_type):
583+
continue
584+
585+
# Fallback to key=value parsing with error handling
546586
try:
547-
temp = shell_safe_json_parse(s)
548-
if isinstance(temp, list): # a bit messy, but we'd like accept the output of the "list" command
549-
for t in temp:
550-
if 'slotSetting' in t.keys():
551-
slot_result[t['name']] = t['slotSetting']
552-
elif setting_type == "SlotSettings":
553-
slot_result[t['name']] = True
554-
result[t['name']] = t['value']
555-
else:
556-
dest.update(temp)
557-
except CLIError:
558-
setting_name, value = s.split('=', 1)
559-
dest[setting_name] = value
560-
result.update(dest)
587+
k, v = s.split('=', 1)
588+
except ValueError as ex:
589+
raise InvalidArgumentValueError(
590+
f"Invalid setting format: '{s}'. Expected 'key=value' format or valid JSON.",
591+
recommendation="Use 'key=value' format or provide valid JSON like '{\"key\": \"value\"}'."
592+
) from ex
593+
594+
result[k] = v
595+
if setting_type == "SlotSettings":
596+
slot_result[k] = True
561597

562598
for setting_name, value in result.items():
563599
app_settings.properties[setting_name] = value
564600
client = web_client_factory(cmd.cli_ctx)
565601

566-
567-
# TODO: Centauri currently return wrong payload for update appsettings, remove this once backend has the fix.
568-
if is_centauri_functionapp(cmd, resource_group_name, name):
569-
update_application_settings_polling(cmd, resource_group_name, name, app_settings, slot, client)
570-
result = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'list_application_settings', slot)
571-
else:
572-
result = _generic_settings_operation(cmd.cli_ctx, resource_group_name, name,
573-
'update_application_settings',
574-
app_settings, slot, client)
575-
602+
# Process slot configurations before updating application settings to ensure proper configuration order.
576603
app_settings_slot_cfg_names = []
577604
if slot_result:
578605
slot_cfg_names = client.web_apps.list_slot_configuration_names(resource_group_name, name)
@@ -586,6 +613,15 @@ def update_app_settings(cmd, resource_group_name, name, settings=None, slot=None
586613
app_settings_slot_cfg_names = slot_cfg_names.app_setting_names
587614
client.web_apps.update_slot_configuration_names(resource_group_name, name, slot_cfg_names)
588615

616+
# TODO: Centauri currently return wrong payload for update appsettings, remove this once backend has the fix.
617+
if is_centauri_functionapp(cmd, resource_group_name, name):
618+
update_application_settings_polling(cmd, resource_group_name, name, app_settings, slot, client)
619+
result = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'list_application_settings', slot)
620+
else:
621+
result = _generic_settings_operation(cmd.cli_ctx, resource_group_name, name,
622+
'update_application_settings',
623+
app_settings, slot, client)
624+
589625
return _build_app_settings_output(result.properties, app_settings_slot_cfg_names, redact=True)
590626

591627

@@ -605,7 +641,7 @@ def update_application_settings_polling(cmd, resource_group_name, name, app_sett
605641
time.sleep(5)
606642
r = send_raw_request(cmd.cli_ctx, method='get', url=poll_url)
607643
else:
608-
raise CLIError(ex)
644+
raise AzureResponseError(f"Failed to update application settings: {str(ex)}") from ex
609645

610646

611647
def add_azure_storage_account(cmd, resource_group_name, name, custom_id, storage_type, account_name,

src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010

1111
from azure.mgmt.web import WebSiteManagementClient
1212
from knack.util import CLIError
13+
from azure.cli.core.azclierror import (InvalidArgumentValueError,
14+
MutuallyExclusiveArgumentError,
15+
AzureResponseError)
1316
from azure.cli.command_modules.appservice.custom import (set_deployment_user,
1417
update_git_token, add_hostname,
1518
update_site_configs,
@@ -27,7 +30,9 @@
2730
list_snapshots,
2831
restore_snapshot,
2932
create_managed_ssl_cert,
30-
add_github_actions)
33+
add_github_actions,
34+
update_app_settings,
35+
update_application_settings_polling)
3136

3237
# pylint: disable=line-too-long
3338
from azure.cli.core.profiles import ResourceType
@@ -463,6 +468,141 @@ def test_create_managed_ssl_cert(self, generic_site_op_mock, client_factory_mock
463468
certificate_envelope=cert_def)
464469

465470

471+
def test_update_app_settings_error_handling_no_parameters(self):
472+
"""Test that MutuallyExclusiveArgumentError is raised when neither settings nor slot_settings are provided."""
473+
cmd_mock = _get_test_cmd()
474+
475+
# Test missing both parameters - should fail early without calling any services
476+
with self.assertRaisesRegex(MutuallyExclusiveArgumentError,
477+
"Please provide either --settings or --slot-settings parameter"):
478+
update_app_settings(cmd_mock, 'test-rg', 'test-app')
479+
480+
@mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation')
481+
@mock.patch('azure.cli.command_modules.appservice.custom.shell_safe_json_parse')
482+
def test_update_app_settings_error_handling_invalid_format(self, mock_json_parse, mock_site_op):
483+
"""Test that InvalidArgumentValueError is raised for invalid setting formats."""
484+
cmd_mock = _get_test_cmd()
485+
486+
# Setup minimal mocks needed to reach the error handling code
487+
mock_app_settings = mock.MagicMock()
488+
mock_app_settings.properties = {}
489+
mock_site_op.return_value = mock_app_settings
490+
491+
# Mock shell_safe_json_parse to raise InvalidArgumentValueError (simulating invalid JSON)
492+
mock_json_parse.side_effect = InvalidArgumentValueError("Invalid JSON format")
493+
494+
# Test invalid format that can't be parsed as JSON or key=value
495+
invalid_setting = "invalid_format_no_equals_no_json"
496+
expected_message = r"Invalid setting format.*Expected 'key=value' format or valid JSON"
497+
498+
with self.assertRaisesRegex(InvalidArgumentValueError, expected_message):
499+
update_app_settings(cmd_mock, 'test-rg', 'test-app', settings=[invalid_setting])
500+
501+
@mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation')
502+
@mock.patch('azure.cli.command_modules.appservice.custom.shell_safe_json_parse')
503+
def test_update_app_settings_error_handling_invalid_format_no_equals(self, mock_json_parse, mock_site_op):
504+
"""Test ValueError path when shell_safe_json_parse raises InvalidArgumentValueError and string contains no '='."""
505+
cmd_mock = _get_test_cmd()
506+
507+
# Setup minimal mocks needed to reach the error handling code
508+
mock_app_settings = mock.MagicMock()
509+
mock_app_settings.properties = {}
510+
mock_site_op.return_value = mock_app_settings
511+
512+
# Mock shell_safe_json_parse to raise InvalidArgumentValueError
513+
mock_json_parse.side_effect = InvalidArgumentValueError("Invalid JSON format")
514+
515+
# Test invalid format with no equals sign - this should trigger ValueError in split('=', 1)
516+
invalid_setting_no_equals = "invalidformatthatcontainsnoequalsign"
517+
expected_message = r"Invalid setting format.*Expected 'key=value' format or valid JSON"
518+
519+
with self.assertRaisesRegex(InvalidArgumentValueError, expected_message):
520+
update_app_settings(cmd_mock, 'test-rg', 'test-app', settings=[invalid_setting_no_equals])
521+
522+
@mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation')
523+
@mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory')
524+
@mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp')
525+
@mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation')
526+
@mock.patch('azure.cli.command_modules.appservice.custom._build_app_settings_output')
527+
def test_update_app_settings_success_key_value_format(self, mock_build, mock_settings_op, mock_centauri,
528+
mock_client_factory, mock_site_op):
529+
"""Test successful processing of key=value format settings."""
530+
cmd_mock = _get_test_cmd()
531+
532+
# Setup mocks
533+
mock_app_settings = mock.MagicMock()
534+
mock_app_settings.properties = {}
535+
mock_site_op.return_value = mock_app_settings
536+
537+
mock_client = mock.MagicMock()
538+
mock_client_factory.return_value = mock_client
539+
mock_centauri.return_value = False
540+
mock_settings_op.return_value = mock_app_settings
541+
mock_build.return_value = {"KEY1": "value1", "KEY2": "value2"}
542+
543+
# Test valid key=value format
544+
result = update_app_settings(cmd_mock, 'test-rg', 'test-app',
545+
settings=['KEY1=value1', 'KEY2=value2'])
546+
547+
# Verify the function completed successfully
548+
self.assertEqual(result["KEY1"], "value1")
549+
self.assertEqual(result["KEY2"], "value2")
550+
mock_build.assert_called_once()
551+
552+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
553+
def test_update_application_settings_polling_error_handling(self, mock_send_request):
554+
"""Test that AzureResponseError is raised in polling function when appropriate."""
555+
cmd_mock = _get_test_cmd()
556+
557+
# Mock an exception that doesn't have the expected structure
558+
class MockException(Exception):
559+
def __init__(self):
560+
self.response = mock.MagicMock()
561+
self.response.status_code = 400 # Not 202
562+
self.response.headers = {}
563+
564+
# Mock _generic_settings_operation to raise the exception
565+
with mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation') as mock_settings_op, \
566+
self.assertRaisesRegex(AzureResponseError, "Failed to update application settings"):
567+
mock_settings_op.side_effect = MockException()
568+
update_application_settings_polling(cmd_mock, 'test-rg', 'test-app',
569+
mock.MagicMock(), None, mock.MagicMock())
570+
571+
@mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation')
572+
@mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory')
573+
@mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp')
574+
@mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation')
575+
@mock.patch('azure.cli.command_modules.appservice.custom._build_app_settings_output')
576+
def test_update_app_settings_success_with_slot_settings(self, mock_build, mock_settings_op, mock_centauri,
577+
mock_client_factory, mock_site_op):
578+
"""Test successful processing with slot settings."""
579+
cmd_mock = _get_test_cmd()
580+
581+
# Setup mocks
582+
mock_app_settings = mock.MagicMock()
583+
mock_app_settings.properties = {}
584+
mock_site_op.return_value = mock_app_settings
585+
586+
mock_client = mock.MagicMock()
587+
mock_slot_config = mock.MagicMock()
588+
mock_slot_config.app_setting_names = []
589+
mock_client.web_apps.list_slot_configuration_names.return_value = mock_slot_config
590+
mock_client_factory.return_value = mock_client
591+
mock_centauri.return_value = False
592+
mock_settings_op.return_value = mock_app_settings
593+
mock_build.return_value = {"SLOT_KEY": "slot_value"}
594+
595+
# Test with slot settings
596+
result = update_app_settings(cmd_mock, 'test-rg', 'test-app',
597+
settings=['REGULAR_KEY=regular_value'],
598+
slot_settings=['SLOT_KEY=slot_value'])
599+
600+
# Verify slot configuration was updated
601+
mock_client.web_apps.list_slot_configuration_names.assert_called_once()
602+
mock_client.web_apps.update_slot_configuration_names.assert_called_once()
603+
mock_build.assert_called_once()
604+
605+
466606
class FakedResponse: # pylint: disable=too-few-public-methods
467607
def __init__(self, status_code):
468608
self.status_code = status_code

0 commit comments

Comments
 (0)