Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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='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:
Expand Down Expand Up @@ -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='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.')
Expand Down
26 changes: 22 additions & 4 deletions src/azure-cli/azure/cli/command_modules/appservice/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -907,8 +907,13 @@ 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:
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"}
Expand Down Expand Up @@ -10974,7 +10979,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)

Expand All @@ -10993,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:
Expand Down Expand Up @@ -11165,7 +11173,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")
Expand Down Expand Up @@ -11417,7 +11425,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
Expand All @@ -11436,6 +11445,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
Expand All @@ -11444,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'):
Expand Down Expand Up @@ -11479,6 +11492,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
Expand Down Expand Up @@ -11619,6 +11633,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


Expand Down Expand Up @@ -11736,6 +11753,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}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,107 @@ 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,linux', reserved=True)
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')

@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
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"""

Expand Down
Loading