From 8f78bf756bca316e3b86033c435ceb78812fefb3 Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Thu, 26 Mar 2026 09:55:39 -0400 Subject: [PATCH 1/2] [App Service] Fix #28836, #30100: Wire through `--protocol` and `--domain-validation-method` params Add --protocol (Smb/Nfs) parameter to `az webapp config storage-account add/update` to support NFS protocol for Azure Files storage mounts (fixes #28836). Add --domain-validation-method parameter to `az webapp config ssl create` to support managed certificate creation for child DNS zones (fixes #30100). Both parameters were already supported by the underlying SDK models but were not wired through the CLI commands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/_help.py | 17 ++++ .../cli/command_modules/appservice/_params.py | 6 ++ .../cli/command_modules/appservice/custom.py | 21 +++-- .../latest/test_webapp_commands_thru_mock.py | 88 ++++++++++++++++++- 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index e0dad92e98c..91c75125106 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1776,6 +1776,8 @@ examples: - name: Create a Managed Certificate for cname.mycustomdomain.com. text: az webapp config ssl create --resource-group MyResourceGroup --name MyWebapp --hostname cname.mycustomdomain.com + - name: Create a Managed Certificate for a child DNS zone using domain validation method. + text: az webapp config ssl create --resource-group MyResourceGroup --name MyWebapp --hostname child.mycustomdomain.com --domain-validation-method TXT """ helps['webapp config storage-account'] = """ @@ -1797,6 +1799,16 @@ --share-name MyShare \\ --access-key MyAccessKey \\ --mount-path /path/to/mount + - name: Add an NFS Azure Files connection with Nfs protocol. + text: > + az webapp config storage-account add -g MyResourceGroup -n MyUniqueApp \\ + --custom-id NfsId \\ + --storage-type AzureFiles \\ + --account-name MyStorageAccount \\ + --share-name MyNfsShare \\ + --access-key MyAccessKey \\ + --mount-path /path/to/mount \\ + --protocol Nfs """ helps['webapp config storage-account delete'] = """ @@ -1828,6 +1840,11 @@ az webapp config storage-account update -g MyResourceGroup -n MyUniqueApp \\ --custom-id CustomId \\ --mount-path /path/to/new/mount + - name: Update the protocol for an existing Azure storage account configuration. + text: > + az webapp config storage-account update -g MyResourceGroup -n MyUniqueApp \\ + --custom-id CustomId \\ + --protocol Nfs - name: Update an existing Azure storage account configuration on a web app. text: az webapp config storage-account update --access-key MyAccessKey --account-name MyAccount --custom-id CustomId --mount-path /path/to/new/mount --name MyUniqueApp --resource-group MyResourceGroup --share-name MyShare --storage-type AzureFiles crafted: true diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index bd4dc0b7ea5..bb5b46824ec 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -521,6 +521,9 @@ def load_arguments(self, _): c.argument('hostname', help='The custom domain name') c.argument('name', options_list=['--name', '-n'], help='Name of the web app.') c.argument('resource-group', options_list=['--resource-group', '-g'], help='Name of resource group.') + c.argument('domain_validation_method', options_list=['--domain-validation-method'], + help='Method used for domain validation. Use this when the certificate needs to validate a ' + 'child DNS zone, e.g. "TXT" for TXT record validation.') with self.argument_context(scope + ' config hostname') as c: c.argument('hostname', completer=get_hostname_completion_list, help="hostname assigned to the site, such as custom domains", id_part='child_name_1') @@ -816,6 +819,9 @@ def load_arguments(self, _): help='the path which the web app uses to read-write data ex: /share1 or /share2') c.argument('slot', options_list=['--slot', '-s'], help="the name of the slot. Default to the productions slot if not specified") + c.argument('protocol', options_list=['--protocol'], + arg_type=get_enum_type(['Smb', 'Nfs']), + help='the protocol used to mount the storage account, e.g. Smb or Nfs') with self.argument_context('webapp config storage-account add') as c: c.argument('slot_setting', options_list=['--slot-setting'], help="With slot setting you can decide to make BYOS configuration sticky to a slot, meaning that when that slot is swapped, the storage account stays with that slot.") with self.argument_context('webapp config storage-account update') as c: diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 386ba088608..5e14c5394f8 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -653,7 +653,8 @@ def update_application_settings_polling(cmd, resource_group_name, name, app_sett def add_azure_storage_account(cmd, resource_group_name, name, custom_id, storage_type, account_name, - share_name, access_key, mount_path=None, slot=None, slot_setting=False): + share_name, access_key, mount_path=None, slot=None, slot_setting=False, + protocol=None): AzureStorageInfoValue = cmd.get_models('AzureStorageInfoValue') azure_storage_accounts = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'list_azure_storage_accounts', slot) @@ -665,7 +666,7 @@ def add_azure_storage_account(cmd, resource_group_name, name, custom_id, storage azure_storage_accounts.properties[custom_id] = AzureStorageInfoValue(type=storage_type, account_name=account_name, share_name=share_name, access_key=access_key, - mount_path=mount_path) + mount_path=mount_path, protocol=protocol) client = web_client_factory(cmd.cli_ctx) result = _generic_settings_operation(cmd.cli_ctx, resource_group_name, name, @@ -684,7 +685,8 @@ def add_azure_storage_account(cmd, resource_group_name, name, custom_id, storage def update_azure_storage_account(cmd, resource_group_name, name, custom_id, storage_type=None, account_name=None, - share_name=None, access_key=None, mount_path=None, slot=None, slot_setting=False): + share_name=None, access_key=None, mount_path=None, slot=None, slot_setting=False, + protocol=None): AzureStorageInfoValue = cmd.get_models('AzureStorageInfoValue') azure_storage_accounts = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, @@ -702,7 +704,8 @@ def update_azure_storage_account(cmd, resource_group_name, name, custom_id, stor account_name=account_name or existing_account_config.account_name, share_name=share_name or existing_account_config.share_name, access_key=access_key or existing_account_config.access_key, - mount_path=mount_path or existing_account_config.mount_path + mount_path=mount_path or existing_account_config.mount_path, + protocol=protocol or existing_account_config.protocol ) azure_storage_accounts.properties[custom_id] = new_account_config @@ -5960,7 +5963,8 @@ def import_ssl_cert(cmd, resource_group_name, key_vault, key_vault_certificate_n certificate_envelope=kv_cert_def) -def create_managed_ssl_cert(cmd, resource_group_name, name, hostname, slot=None, certificate_name=None): +def create_managed_ssl_cert(cmd, resource_group_name, name, hostname, slot=None, certificate_name=None, + domain_validation_method=None): Certificate = cmd.get_models('Certificate') hostname = hostname.lower() client = web_client_factory(cmd.cli_ctx) @@ -5985,8 +5989,11 @@ def create_managed_ssl_cert(cmd, resource_group_name, name, hostname, slot=None, server_farm_id = webapp.server_farm_id location = webapp.location - easy_cert_def = Certificate(location=location, canonical_name=hostname, - server_farm_id=server_farm_id, password='') + cert_kwargs = dict(location=location, canonical_name=hostname, + server_farm_id=server_farm_id, password='') + if domain_validation_method: + cert_kwargs['domain_validation_method'] = domain_validation_method + easy_cert_def = Certificate(**cert_kwargs) # TODO: Update manual polling to use LongRunningOperation once backend API & new SDK supports polling try: 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 853eadc1edd..0f127b322cd 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 @@ -33,7 +33,9 @@ add_github_actions, update_app_settings, update_application_settings_polling, - update_webapp) + update_webapp, + add_azure_storage_account, + update_azure_storage_account) # pylint: disable=line-too-long from azure.cli.core.profiles import ResourceType @@ -468,6 +470,90 @@ def test_create_managed_ssl_cert(self, generic_site_op_mock, client_factory_mock client.certificates.create_or_update.assert_called_once_with(name=host_name, resource_group_name=rg_name, certificate_envelope=cert_def) + @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) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation', autospec=True) + def test_create_managed_ssl_cert_with_domain_validation_method(self, generic_site_op_mock, client_factory_mock, verify_binding_mock): + webapp_name = 'someWebAppName' + rg_name = 'someRgName' + farm_id = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1/providers/Microsoft.Web/serverfarms/farm1' + host_name = 'child.mycustomdomain.com' + + client = mock.MagicMock() + client_factory_mock.return_value = client + cmd_mock = _get_test_cmd() + cli_ctx_mock = mock.MagicMock() + cli_ctx_mock.data = {'subscription_id': 'sub1'} + cmd_mock.cli_ctx = cli_ctx_mock + Site, Certificate = cmd_mock.get_models('Site', 'Certificate') + site = Site(name=webapp_name, location='westeurope') + site.server_farm_id = farm_id + generic_site_op_mock.return_value = site + + verify_binding_mock.return_value = True + create_managed_ssl_cert(cmd_mock, rg_name, webapp_name, host_name, None, + domain_validation_method='TXT') + + cert_def = Certificate(location='westeurope', canonical_name=host_name, + server_farm_id=farm_id, password='', domain_validation_method='TXT') + client.certificates.create_or_update.assert_called_once_with(name=host_name, resource_group_name=rg_name, + certificate_envelope=cert_def) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation', autospec=True) + def test_add_azure_storage_account_with_protocol(self, generic_site_op_mock, client_factory_mock, settings_op_mock): + rg_name = 'someRgName' + webapp_name = 'someWebAppName' + + cmd_mock = _get_test_cmd() + AzureStorageInfoValue = cmd_mock.get_models('AzureStorageInfoValue') + + storage_accounts = mock.MagicMock() + storage_accounts.properties = {} + generic_site_op_mock.return_value = storage_accounts + + result_mock = mock.MagicMock() + result_mock.properties = {} + settings_op_mock.return_value = result_mock + + add_azure_storage_account(cmd_mock, rg_name, webapp_name, custom_id='myId', + storage_type='AzureFiles', account_name='myAccount', + share_name='myShare', access_key='myKey', + mount_path='/mnt/share', protocol='Nfs') + + expected = AzureStorageInfoValue(type='AzureFiles', account_name='myAccount', + share_name='myShare', access_key='myKey', + mount_path='/mnt/share', protocol='Nfs') + self.assertEqual(storage_accounts.properties['myId'].protocol, expected.protocol) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation', autospec=True) + def test_update_azure_storage_account_with_protocol(self, generic_site_op_mock, client_factory_mock, settings_op_mock): + rg_name = 'someRgName' + webapp_name = 'someWebAppName' + + cmd_mock = _get_test_cmd() + AzureStorageInfoValue = cmd_mock.get_models('AzureStorageInfoValue') + + existing_config = AzureStorageInfoValue(type='AzureFiles', account_name='myAccount', + share_name='myShare', access_key='myKey', + mount_path='/mnt/share', protocol='Smb') + storage_accounts = mock.MagicMock() + storage_accounts.properties = {'myId': existing_config} + generic_site_op_mock.return_value = storage_accounts + + result_mock = mock.MagicMock() + result_mock.properties = {} + settings_op_mock.return_value = result_mock + + update_azure_storage_account(cmd_mock, rg_name, webapp_name, custom_id='myId', + protocol='Nfs') + + new_config = storage_accounts.properties['myId'] + self.assertEqual(new_config.protocol, 'Nfs') + def test_update_app_settings_error_handling_no_parameters(self): """Test that MutuallyExclusiveArgumentError is raised when neither settings nor slot_settings are provided.""" From e015feab67f4e16511f173e22751097c7af9a1d2 Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Thu, 26 Mar 2026 10:20:13 -0400 Subject: [PATCH 2/2] Fix linter: add --validation-method alias and test coverage exclusions - Add --validation-method as shorter alias for --domain-validation-method to satisfy option_length_too_long (HIGH severity) linter rule - Add missing_parameter_test_coverage exclusions for new params: domain_validation_method (webapp/functionapp config ssl create) protocol (webapp config storage-account add/update) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- linter_exclusions.yml | 20 +++++++++++++++++++ .../cli/command_modules/appservice/_params.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/linter_exclusions.yml b/linter_exclusions.yml index 444667c011e..baaaff4cb89 100644 --- a/linter_exclusions.yml +++ b/linter_exclusions.yml @@ -2005,6 +2005,11 @@ functionapp config ssl delete: certificate_thumbprint: rule_exclusions: - option_length_too_long +functionapp config ssl create: + parameters: + domain_validation_method: + rule_exclusions: + - missing_parameter_test_coverage functionapp config ssl import: parameters: key_vault_certificate_name: @@ -3922,6 +3927,11 @@ webapp config ssl delete: certificate_thumbprint: rule_exclusions: - option_length_too_long +webapp config ssl create: + parameters: + domain_validation_method: + rule_exclusions: + - missing_parameter_test_coverage webapp config ssl import: parameters: key_vault_certificate_name: @@ -3932,6 +3942,16 @@ webapp config ssl unbind: certificate_thumbprint: rule_exclusions: - option_length_too_long +webapp config storage-account add: + parameters: + protocol: + rule_exclusions: + - missing_parameter_test_coverage +webapp config storage-account update: + parameters: + protocol: + rule_exclusions: + - missing_parameter_test_coverage webapp create: parameters: multicontainer_config_file: diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index bb5b46824ec..c022f34798b 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -521,7 +521,7 @@ def load_arguments(self, _): c.argument('hostname', help='The custom domain name') c.argument('name', options_list=['--name', '-n'], help='Name of the web app.') c.argument('resource-group', options_list=['--resource-group', '-g'], help='Name of resource group.') - c.argument('domain_validation_method', options_list=['--domain-validation-method'], + c.argument('domain_validation_method', options_list=['--domain-validation-method', '--validation-method'], help='Method used for domain validation. Use this when the certificate needs to validate a ' 'child DNS zone, e.g. "TXT" for TXT record validation.') with self.argument_context(scope + ' config hostname') as c: