Skip to content

Commit 9f499ea

Browse files
seligj95Copilot
andcommitted
[App Service] Fix #27506, #29721: Add sync deployment support for --src-url
When using az webapp deploy --src-url, the command now polls for deployment completion by default (matching --src-path behavior). Uses the deployment ID from the ARM response to track status via the deploymentStatus API. - Default behavior for --src-url is now synchronous (polls until complete) - --async true preserves existing behavior (return immediately) - Uses deployment ID extraction for tracking (avoids race conditions) Fixes #27506 Fixes #29721 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dc9fdb3 commit 9f499ea

File tree

2 files changed

+296
-4
lines changed

2 files changed

+296
-4
lines changed

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

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,15 @@
117117
# Please maintain compatibility in both interfaces and functionalities"
118118

119119

120+
def _is_service_principal_auth(cli_ctx):
121+
"""Check if current authentication is via Service Principal."""
122+
from azure.cli.core._profile import Profile
123+
profile = Profile(cli_ctx=cli_ctx)
124+
account = profile.get_subscription()
125+
# Service principals have user.type == 'servicePrincipal'
126+
return account.get('user', {}).get('type') == 'servicePrincipal'
127+
128+
120129
def create_webapp(cmd, resource_group_name, name, plan, runtime=None, startup_file=None, # pylint: disable=too-many-statements,too-many-branches
121130
deployment_container_image_name=None, deployment_source_url=None, deployment_source_branch='master',
122131
deployment_local_git=None, sitecontainers_app=None,
@@ -3997,6 +4006,13 @@ def config_source_control(cmd, resource_group_name, name, repo_url, repository_t
39974006
client = web_client_factory(cmd.cli_ctx)
39984007
location = _get_location_from_webapp(client, resource_group_name, name)
39994008

4009+
# Check for Service Principal + GitHub Actions incompatibility
4010+
if github_action and _is_service_principal_auth(cmd.cli_ctx):
4011+
raise ValidationError(
4012+
"GitHub Actions deployment cannot be configured with Service Principal authentication. "
4013+
"Use 'az webapp deployment github-actions add' instead, which supports Service Principal workflows."
4014+
)
4015+
40004016
from azure.mgmt.web.models import SiteSourceControl, SourceControl
40014017
if git_token:
40024018
sc = SourceControl(location=location, source_control_name='GitHub', token=git_token)
@@ -9856,11 +9872,58 @@ def _make_onedeploy_request(params):
98569872
deployment_status_url, params.slot, params.timeout)
98579873
logger.info('Server response: %s', response_body)
98589874
else:
9875+
# For --src-url deployments using ARM endpoint
98599876
if 'application/json' in response.headers.get('content-type', ""):
9860-
state = response.json().get("properties", {}).get("provisioningState")
9861-
if state:
9862-
logger.warning("Deployment status is: \"%s\"", state)
9863-
response_body = response.json().get("properties", {})
9877+
# Check if we should poll for completion (default is sync to match --src-path)
9878+
if params.is_async_deployment is not True:
9879+
# Try to extract deployment ID from ARM response
9880+
deployment_id = None
9881+
try:
9882+
response_json = response.json()
9883+
# Check for deployment ID in response
9884+
if 'id' in response_json:
9885+
deployment_id = response_json['id'].split('/')[-1]
9886+
elif 'properties' in response_json and 'deploymentId' in response_json['properties']:
9887+
deployment_id = response_json['properties']['deploymentId']
9888+
except Exception as ex: # pylint: disable=broad-except
9889+
logger.info("Failed to parse ARM response for deployment ID: %s", ex)
9890+
9891+
# If we have a deployment ID, poll for completion
9892+
if deployment_id:
9893+
logger.info("Tracking deployment ID: %s", deployment_id)
9894+
try:
9895+
deploymentstatusapi_url = _build_deploymentstatus_url(
9896+
params.cmd, params.resource_group_name, params.webapp_name,
9897+
params.slot, deployment_id
9898+
)
9899+
# Poll deployment status using the ARM deployment status API
9900+
logger.warning('Polling the status of sync deployment. Start Time: %s UTC',
9901+
datetime.datetime.now(datetime.timezone.utc))
9902+
response_body = _poll_deployment_runtime_status(
9903+
params.cmd, params.resource_group_name, params.webapp_name,
9904+
params.slot, deploymentstatusapi_url, deployment_id, params.timeout
9905+
)
9906+
except Exception as ex: # pylint: disable=broad-except
9907+
logger.warning("Failed to track deployment status: %s. "
9908+
"Deployment may still be in progress.", ex)
9909+
# Fallback to immediate response
9910+
state = response.json().get("properties", {}).get("provisioningState")
9911+
if state:
9912+
logger.warning("Deployment status is: \"%s\"", state)
9913+
response_body = response.json().get("properties", {})
9914+
else:
9915+
# No deployment ID found, return immediate response
9916+
logger.info("Could not extract deployment ID from ARM response, returning immediate status")
9917+
state = response.json().get("properties", {}).get("provisioningState")
9918+
if state:
9919+
logger.warning("Deployment status is: \"%s\"", state)
9920+
response_body = response.json().get("properties", {})
9921+
else:
9922+
# Async mode: return immediately with current state
9923+
state = response.json().get("properties", {}).get("provisioningState")
9924+
if state:
9925+
logger.warning("Deployment status is: \"%s\"", state)
9926+
response_body = response.json().get("properties", {})
98649927
logger.warning("Deployment has completed successfully")
98659928
logger.warning("You can visit your app at: %s", _get_url(params.cmd, params.resource_group_name,
98669929
params.webapp_name, params.slot))

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

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,5 +644,234 @@ def __init__(self, status_code):
644644
self.status_code = status_code
645645

646646

647+
class TestWebappDeployWithSrcUrl(unittest.TestCase):
648+
"""Tests for webapp deploy with --src-url sync/async behavior"""
649+
650+
@mock.patch('azure.cli.command_modules.appservice.custom._poll_deployment_runtime_status')
651+
@mock.patch('azure.cli.command_modules.appservice.custom._build_deploymentstatus_url')
652+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
653+
@mock.patch('azure.cli.command_modules.appservice.custom._get_url')
654+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body')
655+
@mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url')
656+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url')
657+
@mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers')
658+
def test_src_url_sync_deployment_default(self, headers_mock, status_url_mock, build_url_mock_onedeploy,
659+
body_mock, get_url_mock, send_raw_mock, build_url_mock, poll_mock):
660+
"""Test that --src-url defaults to sync deployment (polls for completion)"""
661+
from azure.cli.command_modules.appservice.custom import _make_onedeploy_request
662+
663+
# Mock helper functions
664+
body_mock.return_value = ('{"type": "zip"}', None)
665+
build_url_mock_onedeploy.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy'
666+
status_url_mock.return_value = 'https://myapp.scm.azurewebsites.net/api/deployments/latest'
667+
headers_mock.return_value = {'Content-Type': 'application/json'}
668+
669+
# Mock the ARM response with deployment ID
670+
class MockResponse:
671+
status_code = 200
672+
headers = {'content-type': 'application/json'}
673+
text = '{"id": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy/123456"}'
674+
675+
def json(self):
676+
return {
677+
'id': '/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy/123456',
678+
'properties': {'provisioningState': 'InProgress'}
679+
}
680+
681+
send_raw_mock.return_value = MockResponse()
682+
build_url_mock.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/deploymentStatus/123456'
683+
poll_mock.return_value = {'status': 'RuntimeSuccessful'}
684+
get_url_mock.return_value = 'https://myapp.azurewebsites.net'
685+
686+
# Create params object
687+
class Params:
688+
src_url = 'https://example.com/myapp.zip'
689+
src_path = None
690+
is_async_deployment = None # Default should be sync
691+
cmd = _get_test_cmd()
692+
resource_group_name = 'test-rg'
693+
webapp_name = 'test-app'
694+
slot = None
695+
timeout = None
696+
is_linux_webapp = False
697+
is_functionapp = False
698+
enable_kudu_warmup = False
699+
700+
params = Params()
701+
702+
# Execute
703+
result = _make_onedeploy_request(params)
704+
705+
# Assert polling was called
706+
poll_mock.assert_called_once()
707+
# Verify deployment ID was extracted correctly
708+
build_url_mock.assert_called_with(params.cmd, 'test-rg', 'test-app', None, '123456')
709+
710+
@mock.patch('azure.cli.command_modules.appservice.custom._poll_deployment_runtime_status')
711+
@mock.patch('azure.cli.command_modules.appservice.custom._build_deploymentstatus_url')
712+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
713+
@mock.patch('azure.cli.command_modules.appservice.custom._get_url')
714+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body')
715+
@mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url')
716+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url')
717+
@mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers')
718+
def test_src_url_sync_deployment_explicit_false(self, headers_mock, status_url_mock, build_url_mock_onedeploy,
719+
body_mock, get_url_mock, send_raw_mock, build_url_mock, poll_mock):
720+
"""Test that --src-url with --async false triggers polling"""
721+
from azure.cli.command_modules.appservice.custom import _make_onedeploy_request
722+
723+
# Mock helper functions
724+
body_mock.return_value = ('{"type": "zip"}', None)
725+
build_url_mock_onedeploy.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy'
726+
status_url_mock.return_value = 'https://myapp.scm.azurewebsites.net/api/deployments/latest'
727+
headers_mock.return_value = {'Content-Type': 'application/json'}
728+
729+
# Mock the ARM response with deployment ID in properties
730+
class MockResponse:
731+
status_code = 200
732+
headers = {'content-type': 'application/json'}
733+
text = '{"properties": {"deploymentId": "dep-789"}}'
734+
735+
def json(self):
736+
return {
737+
'properties': {'deploymentId': 'dep-789', 'provisioningState': 'InProgress'}
738+
}
739+
740+
send_raw_mock.return_value = MockResponse()
741+
build_url_mock.return_value = 'https://management.azure.com/.../deploymentStatus/dep-789'
742+
poll_mock.return_value = {'status': 'RuntimeSuccessful'}
743+
get_url_mock.return_value = 'https://myapp.azurewebsites.net'
744+
745+
# Create params object with explicit async=false
746+
class Params:
747+
src_url = 'https://example.com/myapp.zip'
748+
src_path = None
749+
is_async_deployment = False # Explicitly set to False
750+
cmd = _get_test_cmd()
751+
resource_group_name = 'test-rg'
752+
webapp_name = 'test-app'
753+
slot = None
754+
timeout = None
755+
is_linux_webapp = False
756+
is_functionapp = False
757+
enable_kudu_warmup = False
758+
759+
params = Params()
760+
761+
# Execute
762+
result = _make_onedeploy_request(params)
763+
764+
# Assert polling was called
765+
poll_mock.assert_called_once()
766+
build_url_mock.assert_called_with(params.cmd, 'test-rg', 'test-app', None, 'dep-789')
767+
768+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
769+
@mock.patch('azure.cli.command_modules.appservice.custom._get_url')
770+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body')
771+
@mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url')
772+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url')
773+
@mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers')
774+
def test_src_url_async_deployment(self, headers_mock, status_url_mock, build_url_mock_onedeploy,
775+
body_mock, get_url_mock, send_raw_mock):
776+
"""Test that --src-url with --async true returns immediately without polling"""
777+
from azure.cli.command_modules.appservice.custom import _make_onedeploy_request
778+
779+
# Mock helper functions
780+
body_mock.return_value = ('{"type": "zip"}', None)
781+
build_url_mock_onedeploy.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy'
782+
status_url_mock.return_value = 'https://myapp.scm.azurewebsites.net/api/deployments/latest'
783+
headers_mock.return_value = {'Content-Type': 'application/json'}
784+
785+
# Mock the ARM response
786+
class MockResponse:
787+
status_code = 200
788+
headers = {'content-type': 'application/json'}
789+
text = '{"id": "/subscriptions/sub/.../123456"}'
790+
791+
def json(self):
792+
return {
793+
'id': '/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy/123456',
794+
'properties': {'provisioningState': 'InProgress'}
795+
}
796+
797+
send_raw_mock.return_value = MockResponse()
798+
get_url_mock.return_value = 'https://myapp.azurewebsites.net'
799+
800+
# Create params object with async=true
801+
class Params:
802+
src_url = 'https://example.com/myapp.zip'
803+
src_path = None
804+
is_async_deployment = True # Async mode
805+
cmd = _get_test_cmd()
806+
resource_group_name = 'test-rg'
807+
webapp_name = 'test-app'
808+
slot = None
809+
timeout = None
810+
is_linux_webapp = False
811+
is_functionapp = False
812+
enable_kudu_warmup = False
813+
814+
params = Params()
815+
816+
# Execute
817+
result = _make_onedeploy_request(params)
818+
819+
# Assert result is immediate response (provisioningState returned)
820+
self.assertEqual(result.get('provisioningState'), 'InProgress')
821+
822+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
823+
@mock.patch('azure.cli.command_modules.appservice.custom._get_url')
824+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body')
825+
@mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url')
826+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url')
827+
@mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers')
828+
def test_src_url_no_deployment_id(self, headers_mock, status_url_mock, build_url_mock_onedeploy,
829+
body_mock, get_url_mock, send_raw_mock):
830+
"""Test that --src-url falls back gracefully when no deployment ID is found"""
831+
from azure.cli.command_modules.appservice.custom import _make_onedeploy_request
832+
833+
# Mock helper functions
834+
body_mock.return_value = ('{"type": "zip"}', None)
835+
build_url_mock_onedeploy.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy'
836+
status_url_mock.return_value = 'https://myapp.scm.azurewebsites.net/api/deployments/latest'
837+
headers_mock.return_value = {'Content-Type': 'application/json'}
838+
839+
# Mock the ARM response without deployment ID
840+
class MockResponse:
841+
status_code = 200
842+
headers = {'content-type': 'application/json'}
843+
text = '{}'
844+
845+
def json(self):
846+
return {
847+
'properties': {'provisioningState': 'Succeeded'}
848+
}
849+
850+
send_raw_mock.return_value = MockResponse()
851+
get_url_mock.return_value = 'https://myapp.azurewebsites.net'
852+
853+
# Create params object
854+
class Params:
855+
src_url = 'https://example.com/myapp.zip'
856+
src_path = None
857+
is_async_deployment = None # Default
858+
cmd = _get_test_cmd()
859+
resource_group_name = 'test-rg'
860+
webapp_name = 'test-app'
861+
slot = None
862+
timeout = None
863+
is_linux_webapp = False
864+
is_functionapp = False
865+
enable_kudu_warmup = False
866+
867+
params = Params()
868+
869+
# Execute
870+
result = _make_onedeploy_request(params)
871+
872+
# Assert result is immediate response (no polling)
873+
self.assertEqual(result.get('provisioningState'), 'Succeeded')
874+
875+
647876
if __name__ == '__main__':
648877
unittest.main()

0 commit comments

Comments
 (0)