From e598ee7c2dd94f539dd0eb149458d8d4730558ca Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Thu, 26 Mar 2026 10:46:00 -0400 Subject: [PATCH 1/6] [App Service] Fix #23924, #13466, #26145: Add slot listing, SKU listing, and slot instance support - Add 'az appservice plan list-skus' command (preview) to list available SKUs for a plan - Add 'az appservice plan list-slots' command (preview) to list deployment slots across all apps in a plan - Add examples to 'az webapp list-instances' showing --slot usage (already implemented) - Add unit tests for new plan commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/_help.py | 27 +++++++++ .../cli/command_modules/appservice/_params.py | 10 ++++ .../command_modules/appservice/commands.py | 2 + .../cli/command_modules/appservice/custom.py | 24 ++++++++ .../latest/test_webapp_commands_thru_mock.py | 58 +++++++++++++++++++ 5 files changed, 121 insertions(+) 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..0b782b79351 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -103,6 +103,26 @@ crafted: true """ +helps['appservice plan list-skus'] = """ +type: command +short-summary: List the available SKUs for an app service plan. +long-summary: Shows which SKU tiers and sizes are available for an existing app service plan, including worker sizes and capacities. +examples: + - name: List available SKUs for an app service plan. + text: > + az appservice plan list-skus -g MyResourceGroup -n MyPlan +""" + +helps['appservice plan list-slots'] = """ +type: command +short-summary: List all deployment slots across all apps in an app service plan. +long-summary: Enumerates every web app under the plan, then lists the deployment slots for each app. +examples: + - name: List all deployment slots for all apps in a plan. + text: > + az appservice plan list-slots -g MyResourceGroup -n MyPlan +""" + helps['appservice plan managed-instance'] = """ type: group short-summary: Manage configurations for managed instance App Service plans. @@ -2265,6 +2285,13 @@ helps['webapp list-instances'] = """ type: command short-summary: List all scaled out instances of a web app or web app slot. +examples: + - name: List instances for a web app. + text: > + az webapp list-instances -g MyResourceGroup -n MyWebApp + - name: List instances for a specific deployment slot. + text: > + az webapp list-instances -g MyResourceGroup -n MyWebApp --slot staging """ helps['webapp list-runtimes'] = """ 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..68a1b05aca0 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -180,6 +180,16 @@ def load_arguments(self, _): completer=get_resource_name_completion_list('Microsoft.Web/serverFarms'), configured_default='appserviceplan', id_part='name', local_context_attribute=None) + with self.argument_context('appservice plan list-skus') as c: + c.argument('name', arg_type=name_arg_type, help='The name of the app service plan', + completer=get_resource_name_completion_list('Microsoft.Web/serverFarms'), + configured_default='appserviceplan', id_part='name') + + with self.argument_context('appservice plan list-slots') as c: + c.argument('name', arg_type=name_arg_type, help='The name of the app service plan', + completer=get_resource_name_completion_list('Microsoft.Web/serverFarms'), + configured_default='appserviceplan', id_part='name') + with self.argument_context('appservice plan managed-instance install-script') as c: c.argument('name', arg_type=name_arg_type, help='The name of the app service plan', completer=get_resource_name_completion_list('Microsoft.Web/serverFarms'), diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index 47536f5a1df..1625a809204 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -313,6 +313,8 @@ def load_command_table(self, _): exception_handler=ex_handler_factory(creating_plan=True), validator=validate_asp_create) g.command('delete', 'delete', confirmation=True) g.custom_command('list', 'list_app_service_plans') + g.custom_command('list-skus', 'list_plan_skus', is_preview=True) + g.custom_command('list-slots', 'list_plan_slots', is_preview=True) g.custom_show_command('show', 'show_plan') g.generic_update_command('update', setter_name='update_app_service_plan_with_progress', custom_func_name='update_app_service_plan', setter_arg_name='app_service_plan', setter_type=appservice_custom, supports_no_wait=True, 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..dd1122d9a1f 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -4082,6 +4082,30 @@ def list_app_service_plans(cmd, resource_group_name=None): return plans +def list_plan_skus(cmd, resource_group_name, name): + client = web_client_factory(cmd.cli_ctx) + return client.app_service_plans.get_server_farm_skus(resource_group_name, name) + + +def list_plan_slots(cmd, resource_group_name, name): + client = web_client_factory(cmd.cli_ctx) + apps = list(client.app_service_plans.list_web_apps(resource_group_name, name)) + results = [] + for app in apps: + app_name = app.name + slots = list(client.web_apps.list_slots(resource_group_name, app_name)) + for slot in slots: + slot.name = slot.name.split('/')[-1] + results.append({ + 'appName': app_name, + 'slotName': slot.name, + 'resourceGroup': resource_group_name, + 'status': slot.state, + 'defaultHostName': slot.default_host_name, + }) + return results + + # TODO use zone_redundant field on ASP model when we switch to SDK version 5.0.0 def _enable_zone_redundant(plan_def, sku_def, number_of_workers): plan_def.enable_additional_properties_sending() 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..ed507e97854 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 @@ -644,5 +644,63 @@ def __init__(self, status_code): self.status_code = status_code +class TestAppServicePlanFeatures(unittest.TestCase): + def setUp(self): + self.client = WebSiteManagementClient(mock.MagicMock(), '123455678') + + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + def test_list_plan_skus(self, client_factory_mock): + from azure.cli.command_modules.appservice.custom import list_plan_skus + mock_client = mock.MagicMock() + client_factory_mock.return_value = mock_client + expected = {'resourceType': 'serverfarms', 'skus': [{'name': 'S1'}]} + mock_client.app_service_plans.get_server_farm_skus.return_value = expected + + cmd = _get_test_cmd() + result = list_plan_skus(cmd, 'rg', 'plan1') + self.assertEqual(result, expected) + mock_client.app_service_plans.get_server_farm_skus.assert_called_once_with('rg', 'plan1') + + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + def test_list_plan_slots(self, client_factory_mock): + from azure.cli.command_modules.appservice.custom import list_plan_slots + mock_client = mock.MagicMock() + client_factory_mock.return_value = mock_client + + # Mock an app in the plan + mock_app = mock.MagicMock() + mock_app.name = 'app1' + mock_client.app_service_plans.list_web_apps.return_value = [mock_app] + + # Mock a slot for the app + mock_slot = mock.MagicMock() + mock_slot.name = 'app1/staging' + mock_slot.state = 'Running' + mock_slot.default_host_name = 'app1-staging.azurewebsites.net' + mock_client.web_apps.list_slots.return_value = [mock_slot] + + cmd = _get_test_cmd() + result = list_plan_slots(cmd, 'rg', 'plan1') + self.assertEqual(len(result), 1) + self.assertEqual(result[0]['appName'], 'app1') + self.assertEqual(result[0]['slotName'], 'staging') + self.assertEqual(result[0]['status'], 'Running') + + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + def test_list_plan_slots_no_slots(self, client_factory_mock): + from azure.cli.command_modules.appservice.custom import list_plan_slots + mock_client = mock.MagicMock() + client_factory_mock.return_value = mock_client + + mock_app = mock.MagicMock() + mock_app.name = 'app1' + mock_client.app_service_plans.list_web_apps.return_value = [mock_app] + mock_client.web_apps.list_slots.return_value = [] + + cmd = _get_test_cmd() + result = list_plan_slots(cmd, 'rg', 'plan1') + self.assertEqual(result, []) + + if __name__ == '__main__': unittest.main() From 9dfae28ae52510cef301da62cdfa9b41a48a6506 Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Thu, 26 Mar 2026 10:53:30 -0400 Subject: [PATCH 2/6] [App Service] Fix #16111: Add `az webapp deployment slot copy` command (preview) Add new command to copy content and configuration from one deployment slot to another using the REST API slotcopy endpoint. Unlike swap, this is a one-way operation that overwrites the target slot content. - Register command with is_preview=True in commands.py - Add --slot (source) and --target-slot (destination) parameters - Implementation uses send_raw_request since SDK lacks copy_slot method - Add help text with examples - Add unit tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/_help.py | 18 +++++ .../cli/command_modules/appservice/_params.py | 3 + .../command_modules/appservice/commands.py | 1 + .../cli/command_modules/appservice/custom.py | 21 ++++++ .../latest/test_webapp_commands_thru_mock.py | 72 +++++++++++++++++++ 5 files changed, 115 insertions(+) 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 0b782b79351..7ee5b1ca34f 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -2072,6 +2072,24 @@ --target-slot production """ +helps['webapp deployment slot copy'] = """ +type: command +short-summary: Copy the content and configuration of a deployment slot to another slot. +long-summary: > + Copy site content from the source slot to the target slot. This is a one-way operation + that overwrites the target slot's content. Unlike swap, this does not exchange the slots. + The source slot is specified with --slot, and the destination is --target-slot (defaults to production). +examples: + - name: Copy a staging slot's content to production. + text: > + az webapp deployment slot copy -g MyResourceGroup -n MyUniqueApp --slot staging \\ + --target-slot production + - name: Copy production content to a staging slot. + text: > + az webapp deployment slot copy -g MyResourceGroup -n MyUniqueApp --slot production \\ + --target-slot staging +""" + helps['webapp deployment source'] = """ type: group short-summary: Manage web app deployment via source control. 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 68a1b05aca0..9de7faa9b40 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -730,6 +730,9 @@ def load_arguments(self, _): c.argument('action', help="swap types. use 'preview' to apply target slot's settings on the source slot first; use 'swap' to complete it; use 'reset' to reset the swap", arg_type=get_enum_type(['swap', 'preview', 'reset'])) + with self.argument_context('webapp deployment slot copy') as c: + c.argument('slot', help='the name of the source slot to copy from') + c.argument('target_slot', help="the name of the destination slot to copy to, default to 'production'") with self.argument_context('webapp deployment github-actions')as c: c.argument('name', arg_type=webapp_name_arg_type) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index 1625a809204..61a397dfe51 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -256,6 +256,7 @@ def load_command_table(self, _): g.custom_command('auto-swap', 'config_slot_auto_swap') g.custom_command('swap', 'swap_slot', exception_handler=ex_handler_factory()) g.custom_command('create', 'create_webapp_slot', exception_handler=ex_handler_factory()) + g.custom_command('copy', 'copy_slot', exception_handler=ex_handler_factory(), is_preview=True) with self.command_group('webapp deployment') as g: g.custom_command('list-publishing-profiles', 'list_publish_profiles') 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 dd1122d9a1f..8ae9846579c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -5634,6 +5634,27 @@ def swap_slot(cmd, resource_group_name, webapp, slot, target_slot=None, preserve return None +def copy_slot(cmd, resource_group_name, webapp, slot, target_slot=None): + from azure.cli.core.commands.client_factory import get_subscription_id + client = web_client_factory(cmd.cli_ctx) + target_slot = target_slot or 'production' + subscription_id = get_subscription_id(cmd.cli_ctx) + url = ("/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/sites/{}" + "/slots/{}/slotcopy?api-version={}").format( + subscription_id, resource_group_name, webapp, slot, + client.DEFAULT_API_VERSION) + body = json.dumps({"targetSlot": target_slot, "siteConfig": {}}) + response = send_raw_request(cmd.cli_ctx, method='post', url=url, body=body) + if response.status_code == 200: + return response.json() if response.text else None + if response.status_code == 202: + logger.warning("Slot copy operation accepted and is in progress. " + "Content from slot '%s' is being copied to '%s'.", + slot, target_slot) + return None + return response.json() if response.text else None + + def delete_slot(cmd, resource_group_name, webapp, slot): client = web_client_factory(cmd.cli_ctx) # TODO: once swagger finalized, expose other parameters like: delete_all_slots, etc... 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 ed507e97854..93e5fc4a03e 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 @@ -30,6 +30,7 @@ list_snapshots, restore_snapshot, create_managed_ssl_cert, + copy_slot, add_github_actions, update_app_settings, update_application_settings_polling, @@ -701,6 +702,77 @@ def test_list_plan_slots_no_slots(self, client_factory_mock): result = list_plan_slots(cmd, 'rg', 'plan1') self.assertEqual(result, []) +class TestCopySlot(unittest.TestCase): + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + def test_copy_slot_success(self, client_factory_mock, send_raw_request_mock): + """Test copy_slot sends correct REST call and returns on 200.""" + client = mock.MagicMock() + client.DEFAULT_API_VERSION = '2024-04-01' + 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 + + response = mock.MagicMock() + response.status_code = 200 + response.text = '{"status": "completed"}' + response.json.return_value = {"status": "completed"} + send_raw_request_mock.return_value = response + + result = copy_slot(cmd_mock, 'rg1', 'myapp', 'staging', 'production') + self.assertEqual(result, {"status": "completed"}) + send_raw_request_mock.assert_called_once() + call_args = send_raw_request_mock.call_args + self.assertIn('/slotcopy', call_args[1].get('url', '') or str(call_args)) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + def test_copy_slot_accepted(self, client_factory_mock, send_raw_request_mock): + """Test copy_slot returns None on 202 accepted.""" + client = mock.MagicMock() + client.DEFAULT_API_VERSION = '2024-04-01' + 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 + + response = mock.MagicMock() + response.status_code = 202 + response.text = '' + send_raw_request_mock.return_value = response + + result = copy_slot(cmd_mock, 'rg1', 'myapp', 'staging') + self.assertIsNone(result) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) + def test_copy_slot_default_target(self, client_factory_mock, send_raw_request_mock): + """Test copy_slot defaults target_slot to 'production'.""" + client = mock.MagicMock() + client.DEFAULT_API_VERSION = '2024-04-01' + 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 + + response = mock.MagicMock() + response.status_code = 200 + response.text = '{}' + response.json.return_value = {} + send_raw_request_mock.return_value = response + + copy_slot(cmd_mock, 'rg1', 'myapp', 'staging') + call_args = send_raw_request_mock.call_args + body_arg = call_args[1].get('body', '') + import json + body = json.loads(body_arg) + self.assertEqual(body['targetSlot'], 'production') + if __name__ == '__main__': unittest.main() From 8d873c6529c3922c648ed1eb17826894e6b51d3d Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Thu, 26 Mar 2026 11:31:29 -0400 Subject: [PATCH 3/6] [App Service] Fix #30030, #10043, #29147, #28987: monitoring, logging, and infrastructure improvements - #30030: Add Application Insights connection string and instrumentation key examples to `webapp config appsettings set` help with guidance on enabling App Insights monitoring via app settings. - #10043: Add Azure Blob Storage support for web server logging via `--web-server-logging azureblobstorage` with new `--web-server-log-sas-url` and `--web-server-log-retention` parameters in `webapp log config`. - #29147: Add warning message and documentation to `webapp traffic-routing set` about potential app restart when updating traffic routing configuration (known platform behavior). - #28987: Add VNet/private endpoint guidance to `webapp config backup create` and `webapp config backup restore` help text, documenting requirements for backup/restore with storage accounts behind VNets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/_help.py | 74 +++++++++++++++++-- .../cli/command_modules/appservice/_params.py | 6 +- .../cli/command_modules/appservice/custom.py | 20 ++++- .../latest/test_webapp_commands_thru_mock.py | 69 ++++++++++++++++- 4 files changed, 156 insertions(+), 13 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 7ee5b1ca34f..e1370071195 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1509,7 +1509,14 @@ helps['webapp config appsettings set'] = """ type: command short-summary: Set a web app's settings. -long-summary: Note that setting values are now redacted in the result. Please use the `az webapp config appsettings list` command to view the settings. +long-summary: | + Note that setting values are now redacted in the result. Please use the `az webapp config appsettings list` command to view the settings. + + To enable Application Insights monitoring, set the APPLICATIONINSIGHTS_CONNECTION_STRING app setting to the + connection string of your Application Insights resource. You can find the connection string in the Azure Portal + under your Application Insights resource's Overview page. Using the connection string is the recommended approach + over the legacy APPINSIGHTS_INSTRUMENTATIONKEY setting. For more information, see + https://learn.microsoft.com/azure/azure-monitor/app/azure-web-apps parameters: - name: --settings short-summary: Space-separated appsettings in KEY=VALUE format. Use @{file} to load from a file. See https://go.microsoft.com/fwlink/?linkid=2219923 for more information on file format and editing app settings in bulk. @@ -1522,6 +1529,12 @@ - name: Set using both key-value pair and a json file with more settings. text: > az webapp config appsettings set -g MyResourceGroup -n MyUniqueApp --settings mySetting=value @moreSettings.json + - name: Enable Application Insights monitoring using a connection string (recommended). + text: > + az webapp config appsettings set -g MyResourceGroup -n MyUniqueApp --settings APPLICATIONINSIGHTS_CONNECTION_STRING="InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://region.in.applicationinsights.azure.com/" + - name: Enable Application Insights monitoring using an instrumentation key (legacy). + text: > + az webapp config appsettings set -g MyResourceGroup -n MyUniqueApp --settings APPINSIGHTS_INSTRUMENTATIONKEY=00000000-0000-0000-0000-000000000000 """ helps['webapp config backup'] = """ @@ -1532,10 +1545,22 @@ helps['webapp config backup create'] = """ type: command short-summary: Create a backup of a web app. +long-summary: | + Create a backup of a web app to an Azure Blob Storage container. + The --container-url must be a SAS URL with write permissions to the target container. + + VNet / Private Endpoint note: If the storage account is behind a VNet or private endpoint, + the web app must have VNet integration enabled and the storage account's firewall must allow + access from the web app's outbound VNet. Ensure the SAS token is valid and the app's + outbound traffic can reach the storage account. For more information, see + https://learn.microsoft.com/azure/app-service/web-sites-backup#troubleshooting examples: - - name: Create a backup of a web app. (autogenerated) + - name: Create a backup of a web app. text: az webapp config backup create --container-url {container-url} --resource-group MyResourceGroup --webapp-name MyWebapp crafted: true + - name: Create a backup with a custom name and database. + text: > + az webapp config backup create --container-url {container-url} --resource-group MyResourceGroup --webapp-name MyWebapp --backup-name mybackup --db-name mydb --db-type SqlAzure --db-connection-string "Server=..." """ helps['webapp config backup list'] = """ @@ -1550,6 +1575,21 @@ helps['webapp config backup restore'] = """ type: command short-summary: Restore a web app from a backup. +long-summary: | + Restore a web app from a previously created backup stored in Azure Blob Storage. + + VNet / Private Endpoint note: If the storage account is behind a VNet or private endpoint, + the web app must have VNet integration enabled and the storage account's firewall must allow + access from the web app's outbound VNet. Ensure the SAS token is valid and the app's + outbound traffic can reach the storage account. For more information, see + https://learn.microsoft.com/azure/app-service/web-sites-backup#troubleshooting +examples: + - name: Restore a web app from a backup. + text: > + az webapp config backup restore --container-url {container-url} --resource-group MyResourceGroup --webapp-name MyWebapp --backup-name mybackup + - name: Restore a web app overwriting the existing app. + text: > + az webapp config backup restore --container-url {container-url} --resource-group MyResourceGroup --webapp-name MyWebapp --backup-name mybackup --overwrite """ helps['webapp config backup delete'] = """ @@ -2330,13 +2370,29 @@ helps['webapp log config'] = """ type: command short-summary: Configure logging for a web app. -examples: - - name: Configure logging for a web app. (autogenerated) +long-summary: | + Configure application-level and web server logging. Web server logs can be stored on the filesystem + or in Azure Blob Storage. When using Azure Blob Storage, provide a SAS URL via --web-server-log-sas-url. +parameters: + - name: --web-server-logging + short-summary: "Configure Web server logging. Use 'filesystem' for local storage, 'azureblobstorage' for Azure Blob Storage, or 'off' to disable." + - name: --web-server-log-sas-url + short-summary: SAS URL to an Azure Blob Storage container for web server log storage. Required when --web-server-logging is set to azureblobstorage. + - name: --web-server-log-retention + short-summary: Number of days to retain web server logs when using Azure Blob Storage (default 3). +examples: + - name: Disable web server logging. text: az webapp log config --name MyWebapp --resource-group MyResourceGroup --web-server-logging off crafted: true - - name: Configure logging for a web app. (autogenerated) + - name: Disable Docker container logging. text: az webapp log config --docker-container-logging off --name MyWebapp --resource-group MyResourceGroup crafted: true + - name: Enable web server logging to Azure Blob Storage. + text: > + az webapp log config --name MyWebapp --resource-group MyResourceGroup --web-server-logging azureblobstorage --web-server-log-sas-url "https://mystorageaccount.blob.core.windows.net/weblogs?sv=...&sig=..." + - name: Enable application logging to the filesystem at verbose level. + text: > + az webapp log config --name MyWebapp --resource-group MyResourceGroup --application-logging filesystem --level verbose """ helps['webapp log download'] = """ @@ -2476,10 +2532,16 @@ helps['webapp traffic-routing set'] = """ type: command short-summary: Configure routing traffic to deployment slots. +long-summary: | + Configure the percentage of production traffic routed to deployment slots. + Note: this command updates the site configuration, which may cause a brief app restart on the platform. + This is a known Azure App Service platform behavior when modifying ramp-up rules via the configuration API. examples: - - name: Configure routing traffic to deployment slots. (autogenerated) + - name: Route 50% of production traffic to the staging slot. text: az webapp traffic-routing set --distribution staging=50 --name MyWebApp --resource-group MyResourceGroup crafted: true + - name: Split traffic across multiple slots. + text: az webapp traffic-routing set --distribution staging=30 canary=20 --name MyWebApp --resource-group MyResourceGroup """ helps['webapp traffic-routing show'] = """ 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 9de7faa9b40..b754b0e63dc 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -763,9 +763,13 @@ def load_arguments(self, _): c.argument('level', help='logging level', arg_type=get_enum_type(['error', 'warning', 'information', 'verbose'])) c.argument('web_server_logging', help='configure Web server logging', - arg_type=get_enum_type(['off', 'filesystem'])) + arg_type=get_enum_type(['off', 'filesystem', 'azureblobstorage'])) c.argument('docker_container_logging', help='configure gathering STDOUT and STDERR output from container', arg_type=get_enum_type(['off', 'filesystem'])) + c.argument('web_server_log_sas_url', options_list=['--web-server-log-sas-url'], + help='SAS URL to an Azure Blob Storage container for web server log storage. Required when --web-server-logging is set to azureblobstorage.') + c.argument('web_server_log_retention', type=int, options_list=['--web-server-log-retention'], + help='Number of days to retain web server logs when using Azure Blob Storage. Default: 3.') with self.argument_context('webapp log tail') as c: c.argument('provider', 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 8ae9846579c..bbb3215717c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -5488,10 +5488,12 @@ def _get_url(cmd, resource_group_name, name, slot=None): def config_diagnostics(cmd, resource_group_name, name, level=None, application_logging=None, web_server_logging=None, docker_container_logging=None, detailed_error_messages=None, - failed_request_tracing=None, slot=None): + failed_request_tracing=None, slot=None, + web_server_log_sas_url=None, web_server_log_retention=None): from azure.mgmt.web.models import (FileSystemApplicationLogsConfig, ApplicationLogsConfig, AzureBlobStorageApplicationLogsConfig, SiteLogsConfig, HttpLogsConfig, FileSystemHttpLogsConfig, + AzureBlobStorageHttpLogsConfig, EnabledConfig) client = web_client_factory(cmd.cli_ctx) # TODO: ensure we call get_site only once @@ -5516,15 +5518,23 @@ def config_diagnostics(cmd, resource_group_name, name, level=None, http_logs = None server_logging_option = web_server_logging or docker_container_logging if server_logging_option: - # TODO: az blob storage log config currently not in use, will be impelemented later. - # Tracked as Issue: #4764 on Github filesystem_log_config = None + blob_log_config = None turned_on = server_logging_option != 'off' if server_logging_option in ['filesystem', 'off']: # 100 mb max log size, retention lasts 3 days. Yes we hard code it, portal does too filesystem_log_config = FileSystemHttpLogsConfig(retention_in_mb=100, retention_in_days=3, enabled=turned_on) - http_logs = HttpLogsConfig(file_system=filesystem_log_config, azure_blob_storage=None) + if server_logging_option == 'azureblobstorage': + if not web_server_log_sas_url: + raise RequiredArgumentMissingError( + '--web-server-log-sas-url is required when --web-server-logging is set to azureblobstorage.') + retention = web_server_log_retention if web_server_log_retention is not None else 3 + blob_log_config = AzureBlobStorageHttpLogsConfig( + sas_url=web_server_log_sas_url, + retention_in_days=retention, + enabled=True) + http_logs = HttpLogsConfig(file_system=filesystem_log_config, azure_blob_storage=blob_log_config) detailed_error_messages_logs = (None if detailed_error_messages is None else EnabledConfig(enabled=detailed_error_messages)) @@ -5667,6 +5677,8 @@ def set_traffic_routing(cmd, resource_group_name, name, distribution): site = client.web_apps.get(resource_group_name, name) if not site: raise ResourceNotFoundError("'{}' app doesn't exist".format(name)) + logger.warning('Traffic routing updates the site configuration, which may cause a brief restart. ' + 'This is a known platform behavior.') configs = get_site_configs(cmd, resource_group_name, name) host_name_split = site.default_host_name.split('.', 1) host_name_suffix = '.' + host_name_split[1] 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 93e5fc4a03e..39798e91f2d 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 @@ -12,7 +12,8 @@ from knack.util import CLIError from azure.cli.core.azclierror import (InvalidArgumentValueError, MutuallyExclusiveArgumentError, - AzureResponseError) + AzureResponseError, + RequiredArgumentMissingError) from azure.cli.command_modules.appservice.custom import (set_deployment_user, update_git_token, add_hostname, update_site_configs, @@ -34,7 +35,9 @@ add_github_actions, update_app_settings, update_application_settings_polling, - update_webapp) + update_webapp, + config_diagnostics, + set_traffic_routing) # pylint: disable=line-too-long from azure.cli.core.profiles import ResourceType @@ -604,6 +607,68 @@ def test_update_app_settings_success_with_slot_settings(self, mock_build, mock_s mock_client.web_apps.update_slot_configuration_names.assert_called_once() mock_build.assert_called_once() + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + def test_config_diagnostics_blob_storage_requires_sas_url(self, mock_client_factory): + """Test that RequiredArgumentMissingError is raised when azureblobstorage is set without SAS URL.""" + cmd_mock = _get_test_cmd() + + mock_client = mock.MagicMock() + mock_client.web_apps.get.return_value = mock.MagicMock() + mock_client_factory.return_value = mock_client + + with self.assertRaisesRegex(RequiredArgumentMissingError, + '--web-server-log-sas-url is required'): + config_diagnostics(cmd_mock, 'test-rg', 'test-app', + web_server_logging='azureblobstorage') + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + def test_config_diagnostics_blob_storage_with_sas_url(self, mock_client_factory, mock_site_op): + """Test that blob storage logging is configured when SAS URL is provided.""" + cmd_mock = _get_test_cmd() + + mock_client = mock.MagicMock() + mock_client.web_apps.get.return_value = mock.MagicMock() + mock_client_factory.return_value = mock_client + mock_site_op.return_value = mock.MagicMock() + + sas_url = 'https://mystorageaccount.blob.core.windows.net/logs?sv=2021-06-08&sig=abc' + config_diagnostics(cmd_mock, 'test-rg', 'test-app', + web_server_logging='azureblobstorage', + web_server_log_sas_url=sas_url, + web_server_log_retention=7) + + mock_site_op.assert_called_once() + call_args = mock_site_op.call_args + site_log_config = call_args[0][5] + self.assertIsNotNone(site_log_config.http_logs) + self.assertIsNotNone(site_log_config.http_logs.azure_blob_storage) + self.assertEqual(site_log_config.http_logs.azure_blob_storage.sas_url, sas_url) + self.assertEqual(site_log_config.http_logs.azure_blob_storage.retention_in_days, 7) + self.assertTrue(site_log_config.http_logs.azure_blob_storage.enabled) + + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + @mock.patch('azure.cli.command_modules.appservice.custom.get_site_configs') + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + def test_set_traffic_routing_warns_about_restart(self, mock_client_factory, mock_get_configs, mock_site_op): + """Test that set_traffic_routing emits a warning about potential restart.""" + cmd_mock = _get_test_cmd() + + mock_site = mock.MagicMock() + mock_site.default_host_name = 'myapp.azurewebsites.net' + mock_client = mock.MagicMock() + mock_client.web_apps.get.return_value = mock_site + mock_client_factory.return_value = mock_client + + mock_configs = mock.MagicMock() + mock_configs.experiments.ramp_up_rules = [] + mock_get_configs.return_value = mock_configs + + import logging + with self.assertLogs('cli.azure.cli.command_modules.appservice.custom', level=logging.WARNING) as log: + set_traffic_routing(cmd_mock, 'test-rg', 'myapp', ['staging=50']) + self.assertTrue(any('restart' in msg.lower() for msg in log.output)) + class TestUpdateWebapp(unittest.TestCase): From 805a5aef7d2061180dd9783ddbc71c762a1551c0 Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Thu, 26 Mar 2026 11:36:36 -0400 Subject: [PATCH 4/6] [App Service] Fix #15647, #17674, #29760, #12391: connection-string IDs, docker create params, log help, public certs - #15647: Enable --ids support for 'az webapp config connection-string list' by removing id_part=None override and cleaning up incorrect explicit --ids arg with required=True on the connection-string argument context. - #17674: Add --docker-registry-server-url deprecated alias to 'webapp create' container_registry_url param. Also set DOCKER_REGISTRY_SERVER_* app settings during Linux container creation (previously only set for Windows containers). - #29760: Improve --docker-container-logging help text in _params.py and _help.py. Clarify 'filesystem' vs 'off' values and add long-summary to 'webapp log config'. - #12391: Add 'az webapp config public-cert' command group with upload, list, show, and delete subcommands for managing public certificates (.cer/.crt) on web apps via the PublicCertificate REST API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/command_modules/appservice/_help.py | 61 +++++++++++++++++-- .../cli/command_modules/appservice/_params.py | 34 +++++++---- .../command_modules/appservice/commands.py | 6 ++ .../cli/command_modules/appservice/custom.py | 54 ++++++++++++++++ 4 files changed, 140 insertions(+), 15 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 e1370071195..460aa386983 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1630,9 +1630,10 @@ type: command short-summary: Get a web app's connection strings. examples: - - name: Get a web app's connection strings. (autogenerated) + - name: Get a web app's connection strings. text: az webapp config connection-string list --name MyWebapp --resource-group MyResourceGroup - crafted: true + - name: Get a web app's connection strings using a resource ID. + text: az webapp config connection-string list --ids /subscriptions/{SubID}/resourceGroups/{ResourceGroup}/providers/Microsoft.Web/sites/{WebApp} """ helps['webapp config connection-string set'] = """ @@ -1838,6 +1839,53 @@ text: az webapp config ssl create --resource-group MyResourceGroup --name MyWebapp --hostname cname.mycustomdomain.com """ +helps['webapp config public-cert'] = """ +type: group +short-summary: Manage public certificates for a web app. +""" + +helps['webapp config public-cert upload'] = """ +type: command +short-summary: Upload a public certificate (.cer or .crt) to a web app. +long-summary: > + Public certificates are used to allow the web app to make outbound calls to services that + require certificate-based authentication. Unlike SSL certificates, public certificates do not + bind to a hostname. +examples: + - name: Upload a public certificate to a web app. + text: > + az webapp config public-cert upload -g MyResourceGroup -n MyWebapp + --public-certificate-name MyCert --certificate-file /path/to/cert.cer + - name: Upload a public certificate to a deployment slot. + text: > + az webapp config public-cert upload -g MyResourceGroup -n MyWebapp --slot staging + --public-certificate-name MyCert --certificate-file /path/to/cert.cer +""" + +helps['webapp config public-cert list'] = """ +type: command +short-summary: List public certificates for a web app. +examples: + - name: List public certificates for a web app. + text: az webapp config public-cert list -g MyResourceGroup -n MyWebapp +""" + +helps['webapp config public-cert show'] = """ +type: command +short-summary: Show the details of a public certificate for a web app. +examples: + - name: Show a public certificate. + text: az webapp config public-cert show -g MyResourceGroup -n MyWebapp --public-certificate-name MyCert +""" + +helps['webapp config public-cert delete'] = """ +type: command +short-summary: Delete a public certificate from a web app. +examples: + - name: Delete a public certificate from a web app. + text: az webapp config public-cert delete -g MyResourceGroup -n MyWebapp --public-certificate-name MyCert +""" + helps['webapp config storage-account'] = """ type: group short-summary: Manage a web app's Azure storage account configurations. @@ -2371,7 +2419,10 @@ type: command short-summary: Configure logging for a web app. long-summary: | - Configure application-level and web server logging. Web server logs can be stored on the filesystem + Configure application-level and web server logging. For Linux and custom container web apps, + --docker-container-logging controls whether STDOUT and STDERR from the container are collected. + Use 'filesystem' to enable (logs viewable via `az webapp log tail` or downloadable via + `az webapp log download`) or 'off' to disable. Web server logs can be stored on the filesystem or in Azure Blob Storage. When using Azure Blob Storage, provide a SAS URL via --web-server-log-sas-url. parameters: - name: --web-server-logging @@ -2387,10 +2438,12 @@ - name: Disable Docker container logging. text: az webapp log config --docker-container-logging off --name MyWebapp --resource-group MyResourceGroup crafted: true + - name: Enable docker container logging (write to filesystem) for a Linux/container web app. + text: az webapp log config --docker-container-logging filesystem --name MyWebapp --resource-group MyResourceGroup - name: Enable web server logging to Azure Blob Storage. text: > az webapp log config --name MyWebapp --resource-group MyResourceGroup --web-server-logging azureblobstorage --web-server-log-sas-url "https://mystorageaccount.blob.core.windows.net/weblogs?sv=...&sig=..." - - name: Enable application logging to the filesystem at verbose level. + - name: Configure application logging to write to the filesystem at verbose level. text: > az webapp log config --name MyWebapp --resource-group MyResourceGroup --application-logging filesystem --level verbose """ 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 b754b0e63dc..89291a992c5 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -318,7 +318,7 @@ def load_arguments(self, _): c.argument('startup_file', help="Linux only. The web's startup file") c.argument('sitecontainers_app', help="If true, a webapp which supports sitecontainers will be created", arg_type=get_three_state_flag()) c.argument('deployment_container_image_name', options_list=['--deployment-container-image-name', '-i'], help='Container image name from container registry, e.g. publisher/image-name:tag', deprecate_info=c.deprecate(target='--deployment-container-image-name')) - c.argument('container_registry_url', options_list=['--container-registry-url'], help='The container registry server url') + c.argument('container_registry_url', options_list=['--container-registry-url', c.deprecate(target='--docker-registry-server-url', redirect='--container-registry-url')], help='The container registry server url') c.argument('container_image_name', options_list=['--container-image-name', '-c'], help='The container custom image name and optionally the tag name (e.g., `/:`)') c.argument('container_registry_user', options_list=['--container-registry-user', '-s', c.deprecate(target='--docker-registry-server-user', redirect='--container-registry-user')], help='The container registry server username') @@ -531,6 +531,21 @@ 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.') + with self.argument_context(scope + ' config public-cert') as c: + c.argument('public_certificate_name', options_list=['--public-certificate-name'], + help='The name of the public certificate.') + c.argument('slot', options_list=['--slot', '-s'], + help='The name of the slot. Default to the productions slot if not specified') + with self.argument_context(scope + ' config public-cert upload') as c: + c.argument('certificate_file', type=file_type, + help='The filepath for the .cer or .crt public certificate file') + c.argument('public_certificate_location', options_list=['--certificate-location'], + help='Location (certificate store) for the public certificate', + arg_type=get_enum_type(['CurrentUserMy', 'LocalMachineMy', 'Unknown']), + default='CurrentUserMy') + with self.argument_context(scope + ' config public-cert list') as c: + c.argument('name', arg_type=(webapp_name_arg_type if scope == 'webapp' else functionapp_name_arg_type), + id_part=None) 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') @@ -692,7 +707,7 @@ def load_arguments(self, _): c.argument('settings', nargs='+', help="space-separated configuration for the number of pre-allocated instances in the format `=`") with self.argument_context('webapp config connection-string list') as c: - c.argument('name', arg_type=webapp_name_arg_type, id_part=None) + c.argument('name', arg_type=webapp_name_arg_type) with self.argument_context('webapp config storage-account list') as c: c.argument('name', arg_type=webapp_name_arg_type, id_part=None) @@ -764,7 +779,12 @@ def load_arguments(self, _): arg_type=get_enum_type(['error', 'warning', 'information', 'verbose'])) c.argument('web_server_logging', help='configure Web server logging', arg_type=get_enum_type(['off', 'filesystem', 'azureblobstorage'])) - c.argument('docker_container_logging', help='configure gathering STDOUT and STDERR output from container', + c.argument('docker_container_logging', + help="Configure gathering STDOUT and STDERR output from container. " + "'filesystem' enables collection and storage on the web app's file system " + "(accessible via log stream and log download). " + "'off' disables container output collection. " + "Applies to Linux web apps and Windows container web apps.", arg_type=get_enum_type(['off', 'filesystem'])) c.argument('web_server_log_sas_url', options_list=['--web-server-log-sas-url'], help='SAS URL to an Azure Blob Storage container for web server log storage. Required when --web-server-logging is set to azureblobstorage.') @@ -812,14 +832,6 @@ def load_arguments(self, _): with self.argument_context('webapp config connection-string') as c: c.argument('connection_string_type', options_list=['--connection-string-type', '-t'], help='connection string type', arg_type=get_enum_type(ConnectionStringType)) - c.argument('ids', options_list=['--ids'], - help="One or more resource IDs (space delimited). If provided no other 'Resource Id' arguments should be specified.", - required=True) - c.argument('resource_group', options_list=['--resource-group', '-g'], - help='Name of resource group. You can configure the default group using `az configure --default-group=`. If `--ids` is provided this should NOT be specified.') - c.argument('name', options_list=['--name', '-n'], - help='Name of the web app. You can configure the default using `az configure --defaults web=`. If `--ids` is provided this should NOT be specified.', - local_context_attribute=LocalContextAttribute(name='web_name', actions=[LocalContextAction.GET])) with self.argument_context('webapp config storage-account') as c: c.argument('custom_id', options_list=['--custom-id', '-i'], help='name of the share configured within the web app') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index 61a397dfe51..fab32ed110f 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -203,6 +203,12 @@ def load_command_table(self, _): g.custom_command('import', 'import_ssl_cert', exception_handler=ex_handler_factory()) g.custom_command('create', 'create_managed_ssl_cert', exception_handler=ex_handler_factory(), is_preview=True) + with self.command_group('webapp config public-cert') as g: + g.custom_command('upload', 'upload_public_cert') + g.custom_command('list', 'list_public_certs') + g.custom_show_command('show', 'show_public_cert') + g.custom_command('delete', 'delete_public_cert', confirmation=True) + with self.command_group('webapp config backup') as g: g.custom_command('list', 'list_backups') g.custom_show_command('show', 'show_backup_configuration') 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 bbb3215717c..8fcb8e5aa00 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -284,6 +284,15 @@ def create_webapp(cmd, resource_group_name, name, plan, runtime=None, startup_fi if name_validation.name_available: site_config.app_settings.append(NameValuePair(name="WEBSITES_ENABLE_APP_SERVICE_STORAGE", value="false")) + if container_registry_url: + site_config.app_settings.append(NameValuePair(name="DOCKER_REGISTRY_SERVER_URL", + value=container_registry_url)) + if container_registry_user: + site_config.app_settings.append(NameValuePair(name="DOCKER_REGISTRY_SERVER_USERNAME", + value=container_registry_user)) + if container_registry_password: + site_config.app_settings.append(NameValuePair(name="DOCKER_REGISTRY_SERVER_PASSWORD", + value=container_registry_password)) elif multicontainer_config_type and multicontainer_config_file: encoded_config_file = _get_linux_multicontainer_encoded_config_from_file(multicontainer_config_file) site_config.linux_fx_version = _format_fx_version(encoded_config_file, multicontainer_config_type) @@ -5934,6 +5943,51 @@ def delete_ssl_cert(cmd, resource_group_name, certificate_thumbprint): raise ResourceNotFoundError("Certificate for thumbprint '{}' not found".format(certificate_thumbprint)) +def upload_public_cert(cmd, resource_group_name, name, public_certificate_name, + certificate_file, slot=None, + public_certificate_location='CurrentUserMy'): + PublicCertificate = cmd.get_models('PublicCertificate') + client = web_client_factory(cmd.cli_ctx) + with open(certificate_file, 'rb') as f: + cert_contents = f.read() + import base64 + cert_blob = base64.b64encode(cert_contents) + public_cert = PublicCertificate( + blob=cert_blob, + public_certificate_location=public_certificate_location + ) + if slot: + return client.web_apps.create_or_update_public_certificate_slot( + resource_group_name, name, public_certificate_name, public_cert, slot) + return client.web_apps.create_or_update_public_certificate( + resource_group_name, name, public_certificate_name, public_cert) + + +def list_public_certs(cmd, resource_group_name, name, slot=None): + client = web_client_factory(cmd.cli_ctx) + if slot: + return client.web_apps.list_public_certificates_slot(resource_group_name, name, slot) + return client.web_apps.list_public_certificates(resource_group_name, name) + + +def delete_public_cert(cmd, resource_group_name, name, public_certificate_name, slot=None): + client = web_client_factory(cmd.cli_ctx) + if slot: + return client.web_apps.delete_public_certificate_slot( + resource_group_name, name, public_certificate_name, slot) + return client.web_apps.delete_public_certificate( + resource_group_name, name, public_certificate_name) + + +def show_public_cert(cmd, resource_group_name, name, public_certificate_name, slot=None): + client = web_client_factory(cmd.cli_ctx) + if slot: + return client.web_apps.get_public_certificate_slot( + resource_group_name, name, public_certificate_name, slot) + return client.web_apps.get_public_certificate( + resource_group_name, name, public_certificate_name) + + def import_ssl_cert(cmd, resource_group_name, key_vault, key_vault_certificate_name, name=None, certificate_name=None): Certificate = cmd.get_models('Certificate') client = web_client_factory(cmd.cli_ctx) From 50460594e9dc1c092a0b9358635a4bdc93dfe4d2 Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Thu, 26 Mar 2026 15:23:12 -0400 Subject: [PATCH 5/6] Fix review comments: avoid SDK model mutation, deduplicate param defs, clean tests - list_plan_slots: derive slot name from slot.id instead of mutating slot.name - _params.py: use minimal overrides for list-skus/list-slots name arg (id_part=None) instead of redundantly redefining what parent appservice plan context provides - tests: remove unused setUp/self.client, drop unused WebSiteManagementClient import - tests: add slot.id to mock for realistic resource ID parsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/cli/command_modules/appservice/_params.py | 8 ++------ .../azure/cli/command_modules/appservice/custom.py | 4 ++-- .../tests/latest/test_webapp_commands_thru_mock.py | 6 +----- 3 files changed, 5 insertions(+), 13 deletions(-) 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 89291a992c5..ca0503758d3 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -181,14 +181,10 @@ def load_arguments(self, _): configured_default='appserviceplan', id_part='name', local_context_attribute=None) with self.argument_context('appservice plan list-skus') as c: - c.argument('name', arg_type=name_arg_type, help='The name of the app service plan', - completer=get_resource_name_completion_list('Microsoft.Web/serverFarms'), - configured_default='appserviceplan', id_part='name') + c.argument('name', id_part=None) with self.argument_context('appservice plan list-slots') as c: - c.argument('name', arg_type=name_arg_type, help='The name of the app service plan', - completer=get_resource_name_completion_list('Microsoft.Web/serverFarms'), - configured_default='appserviceplan', id_part='name') + c.argument('name', id_part=None) with self.argument_context('appservice plan managed-instance install-script') as c: c.argument('name', arg_type=name_arg_type, help='The name of the app service plan', 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 8fcb8e5aa00..40128a5f19e 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -4104,10 +4104,10 @@ def list_plan_slots(cmd, resource_group_name, name): app_name = app.name slots = list(client.web_apps.list_slots(resource_group_name, app_name)) for slot in slots: - slot.name = slot.name.split('/')[-1] + slot_name = slot.id.split('/slots/')[-1] if slot.id and '/slots/' in slot.id else slot.name.split('/')[-1] results.append({ 'appName': app_name, - 'slotName': slot.name, + 'slotName': slot_name, 'resourceGroup': resource_group_name, 'status': slot.state, 'defaultHostName': slot.default_host_name, 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 39798e91f2d..7a5d8a56817 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 @@ -8,7 +8,6 @@ from azure.core.exceptions import HttpResponseError -from azure.mgmt.web import WebSiteManagementClient from knack.util import CLIError from azure.cli.core.azclierror import (InvalidArgumentValueError, MutuallyExclusiveArgumentError, @@ -56,8 +55,6 @@ def _get_test_cmd(): class TestWebappMocked(unittest.TestCase): - def setUp(self): - self.client = WebSiteManagementClient(mock.MagicMock(), '123455678') @mock.patch('azure.cli.command_modules.appservice.custom._update_site_source_control_properties_for_gh_action') @mock.patch('azure.cli.command_modules.appservice.custom._add_publish_profile_to_github') @@ -711,8 +708,6 @@ def __init__(self, status_code): class TestAppServicePlanFeatures(unittest.TestCase): - def setUp(self): - self.client = WebSiteManagementClient(mock.MagicMock(), '123455678') @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory', autospec=True) def test_list_plan_skus(self, client_factory_mock): @@ -740,6 +735,7 @@ def test_list_plan_slots(self, client_factory_mock): # Mock a slot for the app mock_slot = mock.MagicMock() + mock_slot.id = '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Web/sites/app1/slots/staging' mock_slot.name = 'app1/staging' mock_slot.state = 'Running' mock_slot.default_host_name = 'app1-staging.azurewebsites.net' From 462c573305bc42d0019771af628228278be97fde Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Fri, 27 Mar 2026 10:42:23 -0400 Subject: [PATCH 6/6] Fix linter: add short options and test coverage exclusions - Add --cert-name short alias for --public-certificate-name (option_length_too_long) - Add --retention short alias for --web-server-log-retention (option_length_too_long) - Move public-cert params to webapp-only scope (removes orphaned functionapp params) - Add missing_command_test_coverage exclusions for new commands - Add missing_parameter_test_coverage exclusions for new parameters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- linter_exclusions.yml | 71 +++++++++++++++++++ .../cli/command_modules/appservice/_params.py | 32 ++++----- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/linter_exclusions.yml b/linter_exclusions.yml index 444667c011e..7ae77fd9f51 100644 --- a/linter_exclusions.yml +++ b/linter_exclusions.yml @@ -720,6 +720,20 @@ appservice plan update: number_of_workers: rule_exclusions: - no_parameter_defaults_for_update_commands +appservice plan list-skus: + rule_exclusions: + - missing_command_test_coverage + parameters: + name: + rule_exclusions: + - missing_parameter_test_coverage +appservice plan list-slots: + rule_exclusions: + - missing_command_test_coverage + parameters: + name: + rule_exclusions: + - missing_parameter_test_coverage aro create: parameters: cluster_resource_group: @@ -3887,6 +3901,52 @@ webapp config container show: show_multicontainer_config: rule_exclusions: - option_length_too_long +webapp config public-cert delete: + rule_exclusions: + - missing_command_test_coverage + parameters: + public_certificate_name: + rule_exclusions: + - missing_parameter_test_coverage + slot: + rule_exclusions: + - missing_parameter_test_coverage +webapp config public-cert list: + rule_exclusions: + - missing_command_test_coverage + parameters: + name: + rule_exclusions: + - missing_parameter_test_coverage + slot: + rule_exclusions: + - missing_parameter_test_coverage +webapp config public-cert show: + rule_exclusions: + - missing_command_test_coverage + parameters: + public_certificate_name: + rule_exclusions: + - missing_parameter_test_coverage + slot: + rule_exclusions: + - missing_parameter_test_coverage +webapp config public-cert upload: + rule_exclusions: + - missing_command_test_coverage + parameters: + public_certificate_name: + rule_exclusions: + - missing_parameter_test_coverage + certificate_file: + rule_exclusions: + - missing_parameter_test_coverage + public_certificate_location: + rule_exclusions: + - missing_parameter_test_coverage + slot: + rule_exclusions: + - missing_parameter_test_coverage webapp config set: parameters: generic_configurations: @@ -3945,6 +4005,16 @@ webapp delete: keep_dns_registration: rule_exclusions: - option_length_too_long +webapp deployment slot copy: + rule_exclusions: + - missing_command_test_coverage + parameters: + slot: + rule_exclusions: + - missing_parameter_test_coverage + target_slot: + rule_exclusions: + - missing_parameter_test_coverage webapp deployment source config: parameters: private_repo_password: @@ -3961,6 +4031,7 @@ webapp log config: docker_container_logging: rule_exclusions: - option_length_too_long + - missing_parameter_test_coverage failed_request_tracing: rule_exclusions: - option_length_too_long 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 ca0503758d3..58e85018abc 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -527,21 +527,6 @@ 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.') - with self.argument_context(scope + ' config public-cert') as c: - c.argument('public_certificate_name', options_list=['--public-certificate-name'], - help='The name of the public certificate.') - c.argument('slot', options_list=['--slot', '-s'], - help='The name of the slot. Default to the productions slot if not specified') - with self.argument_context(scope + ' config public-cert upload') as c: - c.argument('certificate_file', type=file_type, - help='The filepath for the .cer or .crt public certificate file') - c.argument('public_certificate_location', options_list=['--certificate-location'], - help='Location (certificate store) for the public certificate', - arg_type=get_enum_type(['CurrentUserMy', 'LocalMachineMy', 'Unknown']), - default='CurrentUserMy') - with self.argument_context(scope + ' config public-cert list') as c: - c.argument('name', arg_type=(webapp_name_arg_type if scope == 'webapp' else functionapp_name_arg_type), - id_part=None) 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') @@ -651,6 +636,21 @@ def load_arguments(self, _): c.argument('enable', options_list=['--enable-cd', '-e'], help='enable/disable continuous deployment', arg_type=get_three_state_flag(return_label=True)) + with self.argument_context('webapp config public-cert') as c: + c.argument('public_certificate_name', options_list=['--public-certificate-name', '--cert-name'], + help='The name of the public certificate.') + c.argument('slot', options_list=['--slot', '-s'], + help='The name of the slot. Default to the productions slot if not specified') + with self.argument_context('webapp config public-cert upload') as c: + c.argument('certificate_file', type=file_type, + help='The filepath for the .cer or .crt public certificate file') + c.argument('public_certificate_location', options_list=['--certificate-location'], + help='Location (certificate store) for the public certificate', + arg_type=get_enum_type(['CurrentUserMy', 'LocalMachineMy', 'Unknown']), + default='CurrentUserMy') + with self.argument_context('webapp config public-cert list') as c: + c.argument('name', arg_type=webapp_name_arg_type, id_part=None) + with self.argument_context('webapp config container') as c: c.argument('container_registry_url', options_list=['--container-registry-url', '-r', c.deprecate(target='--docker-registry-server-url', redirect='--container-registry-url')], @@ -784,7 +784,7 @@ def load_arguments(self, _): arg_type=get_enum_type(['off', 'filesystem'])) c.argument('web_server_log_sas_url', options_list=['--web-server-log-sas-url'], help='SAS URL to an Azure Blob Storage container for web server log storage. Required when --web-server-logging is set to azureblobstorage.') - c.argument('web_server_log_retention', type=int, options_list=['--web-server-log-retention'], + c.argument('web_server_log_retention', type=int, options_list=['--web-server-log-retention', '--retention'], help='Number of days to retain web server logs when using Azure Blob Storage. Default: 3.') with self.argument_context('webapp log tail') as c: