Skip to content

Commit c2fa6d7

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 e8ed21b commit c2fa6d7

2 files changed

Lines changed: 340 additions & 5 deletions

File tree

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

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9893,12 +9893,60 @@ def _make_onedeploy_request(params):
98939893
deployment_status_url, params.slot, params.timeout)
98949894
logger.info('Server response: %s', response_body)
98959895
else:
9896+
# For --src-url deployments using ARM endpoint
98969897
if 'application/json' in response.headers.get('content-type', ""):
9897-
state = response.json().get("properties", {}).get("provisioningState")
9898-
if state:
9899-
logger.warning("Deployment status is: \"%s\"", state)
9900-
response_body = response.json().get("properties", {})
9901-
logger.warning("Deployment has completed successfully")
9898+
# Check if we should poll for completion (default is sync to match --src-path)
9899+
if params.is_async_deployment is not True:
9900+
# Try to extract deployment ID from ARM response
9901+
deployment_id = None
9902+
try:
9903+
response_json = response.json()
9904+
# Check for deployment ID in response
9905+
if 'id' in response_json:
9906+
deployment_id = response_json['id'].split('/')[-1]
9907+
elif 'properties' in response_json and 'deploymentId' in response_json['properties']:
9908+
deployment_id = response_json['properties']['deploymentId']
9909+
except Exception as ex: # pylint: disable=broad-except
9910+
logger.info("Failed to parse ARM response for deployment ID: %s", ex)
9911+
9912+
# If we have a deployment ID, poll for completion
9913+
if deployment_id:
9914+
logger.info("Tracking deployment ID: %s", deployment_id)
9915+
try:
9916+
deploymentstatusapi_url = _build_deploymentstatus_url(
9917+
params.cmd, params.resource_group_name, params.webapp_name,
9918+
params.slot, deployment_id
9919+
)
9920+
# Poll deployment status using the ARM deployment status API
9921+
logger.warning('Polling the status of sync deployment. Start Time: %s UTC',
9922+
datetime.datetime.now(datetime.timezone.utc))
9923+
response_body = _poll_deployment_runtime_status(
9924+
params.cmd, params.resource_group_name, params.webapp_name,
9925+
params.slot, deploymentstatusapi_url, deployment_id, params.timeout
9926+
)
9927+
except Exception as ex: # pylint: disable=broad-except
9928+
logger.warning("Failed to track deployment status: %s. "
9929+
"Deployment may still be in progress.", ex)
9930+
# Fallback to immediate response
9931+
state = response.json().get("properties", {}).get("provisioningState")
9932+
if state:
9933+
logger.warning("Deployment status is: \"%s\"", state)
9934+
response_body = response.json().get("properties", {})
9935+
else:
9936+
# No deployment ID found, return immediate response
9937+
logger.info("Could not extract deployment ID from ARM response, returning immediate status")
9938+
state = response.json().get("properties", {}).get("provisioningState")
9939+
if state:
9940+
logger.warning("Deployment status is: \"%s\"", state)
9941+
response_body = response.json().get("properties", {})
9942+
else:
9943+
# Async mode: return immediately with current state
9944+
state = response.json().get("properties", {}).get("provisioningState")
9945+
if state:
9946+
logger.warning("Deployment status is: \"%s\"", state)
9947+
response_body = response.json().get("properties", {})
9948+
if params.is_async_deployment is not True:
9949+
logger.warning("Deployment has completed successfully")
99029950
logger.warning("You can visit your app at: %s", _get_url(params.cmd, params.resource_group_name,
99039951
params.webapp_name, params.slot))
99049952
return response_body

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

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,5 +759,292 @@ def __init__(self, status_code):
759759
self.status_code = status_code
760760

761761

762+
class TestWebappDeployWithSrcUrl(unittest.TestCase):
763+
"""Tests for webapp deploy with --src-url sync/async behavior"""
764+
765+
@mock.patch('azure.cli.command_modules.appservice.custom._poll_deployment_runtime_status')
766+
@mock.patch('azure.cli.command_modules.appservice.custom._build_deploymentstatus_url')
767+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
768+
@mock.patch('azure.cli.command_modules.appservice.custom._get_url')
769+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body')
770+
@mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url')
771+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url')
772+
@mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers')
773+
def test_src_url_sync_deployment_default(self, headers_mock, status_url_mock, build_url_mock_onedeploy,
774+
body_mock, get_url_mock, send_raw_mock, build_url_mock, poll_mock):
775+
"""Test that --src-url defaults to sync deployment (polls for completion)"""
776+
from azure.cli.command_modules.appservice.custom import _make_onedeploy_request
777+
778+
# Mock helper functions
779+
body_mock.return_value = ('{"type": "zip"}', None)
780+
build_url_mock_onedeploy.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy'
781+
status_url_mock.return_value = 'https://myapp.scm.azurewebsites.net/api/deployments/latest'
782+
headers_mock.return_value = {'Content-Type': 'application/json'}
783+
784+
# Mock the ARM response with deployment ID
785+
class MockResponse:
786+
status_code = 200
787+
headers = {'content-type': 'application/json'}
788+
text = '{"id": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy/123456"}'
789+
790+
def json(self):
791+
return {
792+
'id': '/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy/123456',
793+
'properties': {'provisioningState': 'InProgress'}
794+
}
795+
796+
send_raw_mock.return_value = MockResponse()
797+
build_url_mock.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/deploymentStatus/123456'
798+
poll_mock.return_value = {'status': 'RuntimeSuccessful'}
799+
get_url_mock.return_value = 'https://myapp.azurewebsites.net'
800+
801+
# Create params object
802+
class Params:
803+
src_url = 'https://example.com/myapp.zip'
804+
src_path = None
805+
is_async_deployment = None # Default should be sync
806+
cmd = _get_test_cmd()
807+
resource_group_name = 'test-rg'
808+
webapp_name = 'test-app'
809+
slot = None
810+
timeout = None
811+
is_linux_webapp = False
812+
is_functionapp = False
813+
enable_kudu_warmup = False
814+
815+
params = Params()
816+
817+
# Execute
818+
result = _make_onedeploy_request(params)
819+
820+
# Assert polling was called
821+
poll_mock.assert_called_once()
822+
# Verify deployment ID was extracted correctly
823+
build_url_mock.assert_called_with(params.cmd, 'test-rg', 'test-app', None, '123456')
824+
825+
@mock.patch('azure.cli.command_modules.appservice.custom._poll_deployment_runtime_status')
826+
@mock.patch('azure.cli.command_modules.appservice.custom._build_deploymentstatus_url')
827+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
828+
@mock.patch('azure.cli.command_modules.appservice.custom._get_url')
829+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body')
830+
@mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url')
831+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url')
832+
@mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers')
833+
def test_src_url_sync_deployment_explicit_false(self, headers_mock, status_url_mock, build_url_mock_onedeploy,
834+
body_mock, get_url_mock, send_raw_mock, build_url_mock, poll_mock):
835+
"""Test that --src-url with --async false triggers polling"""
836+
from azure.cli.command_modules.appservice.custom import _make_onedeploy_request
837+
838+
# Mock helper functions
839+
body_mock.return_value = ('{"type": "zip"}', None)
840+
build_url_mock_onedeploy.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy'
841+
status_url_mock.return_value = 'https://myapp.scm.azurewebsites.net/api/deployments/latest'
842+
headers_mock.return_value = {'Content-Type': 'application/json'}
843+
844+
# Mock the ARM response with deployment ID in properties
845+
class MockResponse:
846+
status_code = 200
847+
headers = {'content-type': 'application/json'}
848+
text = '{"properties": {"deploymentId": "dep-789"}}'
849+
850+
def json(self):
851+
return {
852+
'properties': {'deploymentId': 'dep-789', 'provisioningState': 'InProgress'}
853+
}
854+
855+
send_raw_mock.return_value = MockResponse()
856+
build_url_mock.return_value = 'https://management.azure.com/.../deploymentStatus/dep-789'
857+
poll_mock.return_value = {'status': 'RuntimeSuccessful'}
858+
get_url_mock.return_value = 'https://myapp.azurewebsites.net'
859+
860+
# Create params object with explicit async=false
861+
class Params:
862+
src_url = 'https://example.com/myapp.zip'
863+
src_path = None
864+
is_async_deployment = False # Explicitly set to False
865+
cmd = _get_test_cmd()
866+
resource_group_name = 'test-rg'
867+
webapp_name = 'test-app'
868+
slot = None
869+
timeout = None
870+
is_linux_webapp = False
871+
is_functionapp = False
872+
enable_kudu_warmup = False
873+
874+
params = Params()
875+
876+
# Execute
877+
result = _make_onedeploy_request(params)
878+
879+
# Assert polling was called
880+
poll_mock.assert_called_once()
881+
build_url_mock.assert_called_with(params.cmd, 'test-rg', 'test-app', None, 'dep-789')
882+
883+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
884+
@mock.patch('azure.cli.command_modules.appservice.custom._get_url')
885+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body')
886+
@mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url')
887+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url')
888+
@mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers')
889+
def test_src_url_async_deployment(self, headers_mock, status_url_mock, build_url_mock_onedeploy,
890+
body_mock, get_url_mock, send_raw_mock):
891+
"""Test that --src-url with --async true returns immediately without polling"""
892+
from azure.cli.command_modules.appservice.custom import _make_onedeploy_request
893+
894+
# Mock helper functions
895+
body_mock.return_value = ('{"type": "zip"}', None)
896+
build_url_mock_onedeploy.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy'
897+
status_url_mock.return_value = 'https://myapp.scm.azurewebsites.net/api/deployments/latest'
898+
headers_mock.return_value = {'Content-Type': 'application/json'}
899+
900+
# Mock the ARM response
901+
class MockResponse:
902+
status_code = 200
903+
headers = {'content-type': 'application/json'}
904+
text = '{"id": "/subscriptions/sub/.../123456"}'
905+
906+
def json(self):
907+
return {
908+
'id': '/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy/123456',
909+
'properties': {'provisioningState': 'InProgress'}
910+
}
911+
912+
send_raw_mock.return_value = MockResponse()
913+
get_url_mock.return_value = 'https://myapp.azurewebsites.net'
914+
915+
# Create params object with async=true
916+
class Params:
917+
src_url = 'https://example.com/myapp.zip'
918+
src_path = None
919+
is_async_deployment = True # Async mode
920+
cmd = _get_test_cmd()
921+
resource_group_name = 'test-rg'
922+
webapp_name = 'test-app'
923+
slot = None
924+
timeout = None
925+
is_linux_webapp = False
926+
is_functionapp = False
927+
enable_kudu_warmup = False
928+
929+
params = Params()
930+
931+
# Execute
932+
result = _make_onedeploy_request(params)
933+
934+
# Assert result is immediate response (provisioningState returned)
935+
self.assertEqual(result.get('provisioningState'), 'InProgress')
936+
937+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
938+
@mock.patch('azure.cli.command_modules.appservice.custom._get_url')
939+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body')
940+
@mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url')
941+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url')
942+
@mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers')
943+
def test_src_url_no_deployment_id(self, headers_mock, status_url_mock, build_url_mock_onedeploy,
944+
body_mock, get_url_mock, send_raw_mock):
945+
"""Test that --src-url falls back gracefully when no deployment ID is found"""
946+
from azure.cli.command_modules.appservice.custom import _make_onedeploy_request
947+
948+
# Mock helper functions
949+
body_mock.return_value = ('{"type": "zip"}', None)
950+
build_url_mock_onedeploy.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy'
951+
status_url_mock.return_value = 'https://myapp.scm.azurewebsites.net/api/deployments/latest'
952+
headers_mock.return_value = {'Content-Type': 'application/json'}
953+
954+
# Mock the ARM response without deployment ID
955+
class MockResponse:
956+
status_code = 200
957+
headers = {'content-type': 'application/json'}
958+
text = '{}'
959+
960+
def json(self):
961+
return {
962+
'properties': {'provisioningState': 'Succeeded'}
963+
}
964+
965+
send_raw_mock.return_value = MockResponse()
966+
get_url_mock.return_value = 'https://myapp.azurewebsites.net'
967+
968+
# Create params object
969+
class Params:
970+
src_url = 'https://example.com/myapp.zip'
971+
src_path = None
972+
is_async_deployment = None # Default
973+
cmd = _get_test_cmd()
974+
resource_group_name = 'test-rg'
975+
webapp_name = 'test-app'
976+
slot = None
977+
timeout = None
978+
is_linux_webapp = False
979+
is_functionapp = False
980+
enable_kudu_warmup = False
981+
982+
params = Params()
983+
984+
# Execute
985+
result = _make_onedeploy_request(params)
986+
987+
# Assert result is immediate response (no polling)
988+
self.assertEqual(result.get('provisioningState'), 'Succeeded')
989+
990+
991+
@mock.patch('azure.cli.command_modules.appservice.custom._poll_deployment_runtime_status',
992+
side_effect=RuntimeError("Simulated polling failure"))
993+
@mock.patch('azure.cli.command_modules.appservice.custom._build_deploymentstatus_url',
994+
return_value='https://management.azure.com/.../deploymentStatus/abc123')
995+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
996+
@mock.patch('azure.cli.command_modules.appservice.custom._get_url',
997+
return_value='https://test-app.azurewebsites.net')
998+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body',
999+
return_value=('{"type": "zip"}', None))
1000+
@mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url',
1001+
return_value='https://management.azure.com/.../onedeploy')
1002+
@mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url',
1003+
return_value='https://myapp.scm.azurewebsites.net/api/deployments/latest')
1004+
@mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers',
1005+
return_value={'Content-Type': 'application/json'})
1006+
def test_src_url_fallback_on_poll_exception(self, headers_mock, status_url_mock,
1007+
build_url_mock_onedeploy, body_mock,
1008+
get_url_mock, send_raw_mock,
1009+
build_status_url_mock, poll_mock):
1010+
"""Test that when _poll_deployment_runtime_status raises, the fallback returns the ARM response."""
1011+
from azure.cli.command_modules.appservice.custom import _make_onedeploy_request
1012+
1013+
class MockResponse:
1014+
status_code = 200
1015+
headers = {'content-type': 'application/json'}
1016+
text = '{"id": "/subs/sub/rg/rg/providers/Microsoft.Web/sites/app/extensions/onedeploy/abc123"}'
1017+
1018+
def json(self):
1019+
return {
1020+
'id': '/subs/sub/rg/rg/providers/Microsoft.Web/sites/app/extensions/onedeploy/abc123',
1021+
'properties': {'provisioningState': 'InProgress', 'deployer': 'ZipDeploy'}
1022+
}
1023+
1024+
send_raw_mock.return_value = MockResponse()
1025+
1026+
class Params:
1027+
src_url = 'https://example.com/myapp.zip'
1028+
src_path = None
1029+
is_async_deployment = None # sync mode
1030+
cmd = _get_test_cmd()
1031+
resource_group_name = 'test-rg'
1032+
webapp_name = 'test-app'
1033+
slot = None
1034+
timeout = None
1035+
is_linux_webapp = False
1036+
is_functionapp = False
1037+
enable_kudu_warmup = False
1038+
1039+
params = Params()
1040+
result = _make_onedeploy_request(params)
1041+
1042+
# Polling was attempted and raised
1043+
poll_mock.assert_called_once()
1044+
# Fallback: ARM response properties are returned instead of crashing
1045+
self.assertIsNotNone(result)
1046+
self.assertEqual(result.get('provisioningState'), 'InProgress')
1047+
self.assertEqual(result.get('deployer'), 'ZipDeploy')
1048+
7621049
if __name__ == '__main__':
7631050
unittest.main()

0 commit comments

Comments
 (0)