From 34d3b9c3caf9344afcec0a8d4578fb3e22888770 Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Thu, 20 Aug 2026 15:42:06 +0530 Subject: [PATCH 1/2] add friendly tag --- .../cli/command_modules/appservice/_params.py | 2 + .../cli/command_modules/appservice/custom.py | 17 +++++-- .../latest/test_webapp_commands_thru_mock.py | 47 +++++++++++++++++++ 3 files changed, 62 insertions(+), 4 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 775fe497149..f74369770a5 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -1066,6 +1066,7 @@ def load_arguments(self, _): c.argument('enriched_errors', options_list=['--enriched-errors'], help='If true, deployment failures will show context-enriched diagnostics with error codes, suggested fixes, and Copilot prompts. Enabled by default; use --enriched-errors false to disable.', arg_type=get_three_state_flag(), default=True) + c.argument('tag', help='A friendly name used to identify the deployment.') c.argument('auto_generated_domain_name_label_scope', options_list=['--domain-name-scope'], help="Specify the scope of uniqueness for the default hostname during resource creation.", arg_type=get_enum_type(AutoGeneratedDomainNameLabelScope)) with self.argument_context('webapp ssh') as c: @@ -1110,6 +1111,7 @@ def load_arguments(self, _): c.argument('enriched_errors', options_list=['--enriched-errors'], help='If true, deployment failures will show context-enriched diagnostics with error codes, suggested fixes, and Copilot prompts. Enabled by default; use --enriched-errors false to disable.', arg_type=get_three_state_flag(), default=True) + c.argument('tag', help='A friendly name used to identify the deployment.') with self.argument_context('functionapp deploy') as c: c.argument('name', options_list=['--name', '-n'], help='Name of the function app to deploy to.') 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 0b50be163bd..ade74625499 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -897,7 +897,7 @@ def enable_zip_deploy_flex(cmd, resource_group_name, name, src, timeout=None, sl # This funtion performs deployment using /zipdeploy for both function app and web app def enable_zip_deploy(cmd, resource_group_name, name, src, timeout=None, slot=None, - track_status=False, enable_kudu_warmup=True, enriched_errors=True): + track_status=False, enable_kudu_warmup=True, enriched_errors=True, tag=None): logger.warning("Getting scm site credentials for zip deployment") try: @@ -909,6 +909,8 @@ def enable_zip_deploy(cmd, resource_group_name, name, src, timeout=None, slot=No app = client.web_apps.get(resource_group_name, name) deployer = '&Deployer=az_cli_functions' if is_functionapp(app) else '' zip_url = scm_url + '/api/zipdeploy?isAsync=true' + deployer + if tag is not None: + zip_url = zip_url + '&tag=' + quote(tag, safe='') deployment_status_url = scm_url + '/api/deployments/latest' additional_headers = {"Content-Type": "application/octet-stream", "Cache-Control": "no-cache"} @@ -10974,7 +10976,7 @@ def get_history_triggered_webjob(cmd, resource_group_name, name, webjob_name, sl def webapp_up(cmd, name=None, resource_group_name=None, plan=None, location=None, sku=None, # pylint: disable=too-many-statements,too-many-branches os_type=None, runtime=None, dryrun=False, logs=False, launch_browser=False, html=False, app_service_environment=None, track_status=True, enable_kudu_warmup=True, basic_auth="", - auto_generated_domain_name_label_scope=None, enriched_errors=True): + auto_generated_domain_name_label_scope=None, enriched_errors=True, tag=None): if not name: name = generate_default_app_name(cmd) @@ -11165,7 +11167,7 @@ def webapp_up(cmd, name=None, resource_group_name=None, plan=None, location=None # zip contents & deploy zip_file_path = zip_contents_from_dir(src_dir, language) enable_zip_deploy(cmd, rg_name, name, zip_file_path, track_status=track_status, - enable_kudu_warmup=enable_kudu_warmup, enriched_errors=enriched_errors) + enable_kudu_warmup=enable_kudu_warmup, enriched_errors=enriched_errors, tag=tag) if launch_browser: logger.warning("Launching app using default browser") @@ -11417,7 +11419,8 @@ def perform_onedeploy_webapp(cmd, slot=None, track_status=True, enable_kudu_warmup=True, - enriched_errors=True): + enriched_errors=True, + tag=None): params = OneDeployParams() params.cmd = cmd @@ -11436,6 +11439,7 @@ def perform_onedeploy_webapp(cmd, params.track_status = track_status params.enable_kudu_warmup = enable_kudu_warmup params.enriched_errors = enriched_errors + params.tag = tag # When a slot is targeted, fetch the slot's Site (not production) so the # cached model matches what every downstream consumer expects — slots have @@ -11479,6 +11483,7 @@ def __init__(self): self.is_linux_webapp = None self.is_functionapp = None self.enriched_errors = True + self.tag = None # Per-invocation caches. Populated during a single deploy and # cleared in _perform_onedeploy_internal's `finally` block. These MUST # NOT be logged, serialized, or accessed outside the current call @@ -11619,6 +11624,9 @@ def _build_onedeploy_scm_url(params): if params.target_path is not None: deploy_url = deploy_url + '&path=' + quote(params.target_path) + if params.tag is not None: + deploy_url = deploy_url + '&tag=' + quote(params.tag, safe='') + return deploy_url @@ -11736,6 +11744,7 @@ def _get_onedeploy_request_body(params): "ignorestack": params.should_ignore_stack, "clean": params.is_clean_deployment, "restart": params.should_restart, + "tag": params.tag, } } body = {"properties": {k: v for k, v in body["properties"].items() if v is not None}} 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 7ff3befa282..93cb5c9f10a 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 @@ -1412,6 +1412,53 @@ def __init__(self, status_code): self.status_code = status_code +class TestOneDeployTag(unittest.TestCase): + + def test_scm_url_includes_encoded_tag(self): + from azure.cli.command_modules.appservice.custom import OneDeployParams, _build_onedeploy_scm_url + params = OneDeployParams() + params.artifact_type = 'zip' + params.tag = 'release 2026/08' + + with mock.patch('azure.cli.command_modules.appservice.custom._get_or_fetch_scm_url', + return_value='https://example.scm.azurewebsites.net'): + result = _build_onedeploy_scm_url(params) + + self.assertEqual(result, 'https://example.scm.azurewebsites.net/api/publish?type=zip&tag=release%202026%2F08') + + @mock.patch('requests.post') + @mock.patch('builtins.open', new_callable=mock.mock_open, read_data=b'zip-content') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', return_value={}) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://example.scm.azurewebsites.net') + def test_zip_deploy_url_includes_encoded_tag(self, _get_scm_url_mock, client_factory_mock, + _get_headers_mock, _open_mock, post_mock): + from azure.cli.command_modules.appservice.custom import enable_zip_deploy + app = mock.MagicMock(kind='app', reserved=False) + client_factory_mock.return_value.web_apps.get.return_value = app + post_mock.return_value.status_code = 0 + + enable_zip_deploy(mock.MagicMock(), 'myRG', 'myApp', 'app.zip', + enable_kudu_warmup=False, tag='release / 1') + + self.assertEqual(post_mock.call_args.args[0], + 'https://example.scm.azurewebsites.net/api/zipdeploy?isAsync=true&tag=release%20%2F%201') + + def test_arm_body_includes_tag(self): + import json + from azure.cli.command_modules.appservice.custom import OneDeployParams, _get_onedeploy_request_body + params = OneDeployParams() + params.src_url = 'https://example.com/app.zip' + params.artifact_type = 'zip' + params.tag = 'release-2026-08' + + body, file_hash = _get_onedeploy_request_body(params) + + self.assertEqual(json.loads(body)['properties']['tag'], 'release-2026-08') + self.assertIsNone(file_hash) + + class TestCreateAppServicePlanDefaults(unittest.TestCase): """Tests for create_app_service_plan default SKU behavior""" From 5a9a4e83833a83aa61c64fbb1a99505b2a304d0a Mon Sep 17 00:00:00 2001 From: Shikha Jha Date: Thu, 20 Aug 2026 16:26:37 +0530 Subject: [PATCH 2/2] only for linux webapp --- .../cli/command_modules/appservice/_params.py | 4 +- .../cli/command_modules/appservice/custom.py | 9 +++ .../latest/test_webapp_commands_thru_mock.py | 56 ++++++++++++++++++- 3 files changed, 66 insertions(+), 3 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 f74369770a5..fe0f179696c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -1066,7 +1066,7 @@ def load_arguments(self, _): c.argument('enriched_errors', options_list=['--enriched-errors'], help='If true, deployment failures will show context-enriched diagnostics with error codes, suggested fixes, and Copilot prompts. Enabled by default; use --enriched-errors false to disable.', arg_type=get_three_state_flag(), default=True) - c.argument('tag', help='A friendly name used to identify the deployment.') + c.argument('tag', help='Linux only. A friendly name used to identify the deployment.') c.argument('auto_generated_domain_name_label_scope', options_list=['--domain-name-scope'], help="Specify the scope of uniqueness for the default hostname during resource creation.", arg_type=get_enum_type(AutoGeneratedDomainNameLabelScope)) with self.argument_context('webapp ssh') as c: @@ -1111,7 +1111,7 @@ def load_arguments(self, _): c.argument('enriched_errors', options_list=['--enriched-errors'], help='If true, deployment failures will show context-enriched diagnostics with error codes, suggested fixes, and Copilot prompts. Enabled by default; use --enriched-errors false to disable.', arg_type=get_three_state_flag(), default=True) - c.argument('tag', help='A friendly name used to identify the deployment.') + c.argument('tag', help='Linux only. A friendly name used to identify the deployment.') with self.argument_context('functionapp deploy') as c: c.argument('name', options_list=['--name', '-n'], help='Name of the function app to deploy to.') 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 ade74625499..2da89516647 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -907,6 +907,9 @@ def enable_zip_deploy(cmd, resource_group_name, name, src, timeout=None, slot=No client = web_client_factory(cmd.cli_ctx) app = client.web_apps.get(resource_group_name, name) + if tag is not None and not is_linux_webapp(app): + logger.warning("--tag is only supported for Linux web apps and will be ignored.") + tag = None deployer = '&Deployer=az_cli_functions' if is_functionapp(app) else '' zip_url = scm_url + '/api/zipdeploy?isAsync=true' + deployer if tag is not None: @@ -10995,6 +10998,9 @@ def webapp_up(cmd, name=None, resource_group_name=None, plan=None, location=None if not os_type: logger.warning("No --os-type specified. Defaulting to '%s'.", os_name) _is_linux = os_name.lower() == LINUX_OS_NAME + if tag is not None and not _is_linux: + logger.warning("--tag is only supported for Linux web apps and will be ignored.") + tag = None helper = _StackRuntimeHelper(cmd, linux=_is_linux, windows=not _is_linux) if runtime: @@ -11448,6 +11454,9 @@ def perform_onedeploy_webapp(cmd, app = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get', slot) params._cached_site = app # pylint: disable=protected-access params.is_linux_webapp = is_linux_webapp(app) + if tag is not None and not params.is_linux_webapp: + logger.warning("--tag is only supported for Linux web apps and will be ignored.") + params.tag = None # Warn that zip deploy won't auto-build on Linux if params.is_linux_webapp and artifact_type in (None, 'zip'): 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 93cb5c9f10a..db55e092b48 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 @@ -1435,7 +1435,7 @@ def test_scm_url_includes_encoded_tag(self): def test_zip_deploy_url_includes_encoded_tag(self, _get_scm_url_mock, client_factory_mock, _get_headers_mock, _open_mock, post_mock): from azure.cli.command_modules.appservice.custom import enable_zip_deploy - app = mock.MagicMock(kind='app', reserved=False) + app = mock.MagicMock(kind='app,linux', reserved=True) client_factory_mock.return_value.web_apps.get.return_value = app post_mock.return_value.status_code = 0 @@ -1445,6 +1445,60 @@ def test_zip_deploy_url_includes_encoded_tag(self, _get_scm_url_mock, client_fac self.assertEqual(post_mock.call_args.args[0], 'https://example.scm.azurewebsites.net/api/zipdeploy?isAsync=true&tag=release%20%2F%201') + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://example.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', return_value={}) + @mock.patch('builtins.open', new_callable=mock.mock_open, read_data=b'zip-content') + @mock.patch('requests.post') + def test_zip_deploy_ignores_tag_for_windows_webapp(self, post_mock, _open_mock, _get_headers_mock, + _get_scm_url_mock, client_factory_mock): + from azure.cli.command_modules.appservice.custom import enable_zip_deploy + app = mock.MagicMock(kind='app', reserved=False) + client_factory_mock.return_value.web_apps.get.return_value = app + post_mock.return_value.status_code = 0 + + with mock.patch('azure.cli.command_modules.appservice.custom.logger.warning') as warning_mock: + enable_zip_deploy(mock.MagicMock(), 'myRG', 'myApp', 'app.zip', tag='windows-tag') + + warning_mock.assert_any_call('--tag is only supported for Linux web apps and will be ignored.') + self.assertNotIn('tag=', post_mock.call_args.args[0]) + + @mock.patch('azure.cli.command_modules.appservice.custom._perform_onedeploy_internal') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_webapp_deploy_ignores_tag_for_windows_webapp(self, site_operation_mock, perform_deploy_mock): + from azure.cli.command_modules.appservice.custom import perform_onedeploy_webapp + site_operation_mock.return_value = mock.MagicMock(kind='app', reserved=False) + + with mock.patch('azure.cli.command_modules.appservice.custom.logger.warning') as warning_mock: + perform_onedeploy_webapp(mock.MagicMock(), 'myRG', 'myApp', tag='windows-tag') + + warning_mock.assert_any_call('--tag is only supported for Linux web apps and will be ignored.') + self.assertIsNone(perform_deploy_mock.call_args.args[0].tag) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_profile_username') + @mock.patch('azure.cli.command_modules.appservice.custom.get_site_availability') + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom.get_plan_to_use', return_value='myPlan') + @mock.patch('azure.cli.command_modules.appservice.custom.check_resource_group_exists', return_value=True) + @mock.patch('azure.cli.command_modules.appservice.custom.get_rg_to_use', return_value='myRG') + @mock.patch('azure.cli.command_modules.appservice.custom.set_location', return_value='westus') + @mock.patch('azure.cli.command_modules.appservice.custom.get_sku_to_use', return_value='F1') + def test_webapp_up_ignores_tag_for_windows_webapp(self, _get_sku_mock, _set_location_mock, _get_rg_mock, + _check_rg_mock, _get_plan_mock, _client_factory_mock, + site_availability_mock, _profile_username_mock): + from azure.cli.command_modules.appservice.custom import webapp_up + site_availability_mock.return_value = mock.MagicMock(name_available=True) + cmd = mock.MagicMock() + cmd.get_models.return_value = mock.MagicMock + + with mock.patch('azure.cli.command_modules.appservice.custom.logger.warning') as warning_mock: + result = webapp_up(cmd, name='myApp', os_type='windows', html=True, + tag='windows-tag', dryrun=True) + + warning_mock.assert_any_call('--tag is only supported for Linux web apps and will be ignored.') + self.assertEqual(result['os'], 'windows') + def test_arm_body_includes_tag(self): import json from azure.cli.command_modules.appservice.custom import OneDeployParams, _get_onedeploy_request_body