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..38cf3daa758 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -9856,12 +9856,60 @@ def _make_onedeploy_request(params): deployment_status_url, params.slot, params.timeout) logger.info('Server response: %s', response_body) else: + # For --src-url deployments using ARM endpoint if 'application/json' in response.headers.get('content-type', ""): - state = response.json().get("properties", {}).get("provisioningState") - if state: - logger.warning("Deployment status is: \"%s\"", state) - response_body = response.json().get("properties", {}) - logger.warning("Deployment has completed successfully") + # Check if we should poll for completion (default is sync to match --src-path) + if params.is_async_deployment is not True: + # Try to extract deployment ID from ARM response + deployment_id = None + try: + response_json = response.json() + # Check for deployment ID in response + if 'id' in response_json: + deployment_id = response_json['id'].split('/')[-1] + elif 'properties' in response_json and 'deploymentId' in response_json['properties']: + deployment_id = response_json['properties']['deploymentId'] + except Exception as ex: # pylint: disable=broad-except + logger.info("Failed to parse ARM response for deployment ID: %s", ex) + + # If we have a deployment ID, poll for completion + if deployment_id: + logger.info("Tracking deployment ID: %s", deployment_id) + try: + deploymentstatusapi_url = _build_deploymentstatus_url( + params.cmd, params.resource_group_name, params.webapp_name, + params.slot, deployment_id + ) + # Poll deployment status using the ARM deployment status API + logger.warning('Polling the status of sync deployment. Start Time: %s UTC', + datetime.datetime.now(datetime.timezone.utc)) + response_body = _poll_deployment_runtime_status( + params.cmd, params.resource_group_name, params.webapp_name, + params.slot, deploymentstatusapi_url, deployment_id, params.timeout + ) + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to track deployment status: %s. " + "Deployment may still be in progress.", ex) + # Fallback to immediate response + state = response.json().get("properties", {}).get("provisioningState") + if state: + logger.warning("Deployment status is: \"%s\"", state) + response_body = response.json().get("properties", {}) + else: + # No deployment ID found, return immediate response + logger.info("Could not extract deployment ID from ARM response, returning immediate status") + state = response.json().get("properties", {}).get("provisioningState") + if state: + logger.warning("Deployment status is: \"%s\"", state) + response_body = response.json().get("properties", {}) + else: + # Async mode: return immediately with current state + state = response.json().get("properties", {}).get("provisioningState") + if state: + logger.warning("Deployment status is: \"%s\"", state) + response_body = response.json().get("properties", {}) + if params.is_async_deployment is not True: + logger.warning("Deployment has completed successfully") logger.warning("You can visit your app at: %s", _get_url(params.cmd, params.resource_group_name, params.webapp_name, params.slot)) return response_body 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..200a328b982 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,292 @@ def __init__(self, status_code): self.status_code = status_code +class TestWebappDeployWithSrcUrl(unittest.TestCase): + """Tests for webapp deploy with --src-url sync/async behavior""" + + @mock.patch('azure.cli.command_modules.appservice.custom._poll_deployment_runtime_status') + @mock.patch('azure.cli.command_modules.appservice.custom._build_deploymentstatus_url') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom._get_url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body') + @mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers') + def test_src_url_sync_deployment_default(self, headers_mock, status_url_mock, build_url_mock_onedeploy, + body_mock, get_url_mock, send_raw_mock, build_url_mock, poll_mock): + """Test that --src-url defaults to sync deployment (polls for completion)""" + from azure.cli.command_modules.appservice.custom import _make_onedeploy_request + + # Mock helper functions + body_mock.return_value = ('{"type": "zip"}', None) + build_url_mock_onedeploy.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy' + status_url_mock.return_value = 'https://myapp.scm.azurewebsites.net/api/deployments/latest' + headers_mock.return_value = {'Content-Type': 'application/json'} + + # Mock the ARM response with deployment ID + class MockResponse: + status_code = 200 + headers = {'content-type': 'application/json'} + text = '{"id": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy/123456"}' + + def json(self): + return { + 'id': '/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy/123456', + 'properties': {'provisioningState': 'InProgress'} + } + + send_raw_mock.return_value = MockResponse() + build_url_mock.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/deploymentStatus/123456' + poll_mock.return_value = {'status': 'RuntimeSuccessful'} + get_url_mock.return_value = 'https://myapp.azurewebsites.net' + + # Create params object + class Params: + src_url = 'https://example.com/myapp.zip' + src_path = None + is_async_deployment = None # Default should be sync + cmd = _get_test_cmd() + resource_group_name = 'test-rg' + webapp_name = 'test-app' + slot = None + timeout = None + is_linux_webapp = False + is_functionapp = False + enable_kudu_warmup = False + + params = Params() + + # Execute + result = _make_onedeploy_request(params) + + # Assert polling was called + poll_mock.assert_called_once() + # Verify deployment ID was extracted correctly + build_url_mock.assert_called_with(params.cmd, 'test-rg', 'test-app', None, '123456') + + @mock.patch('azure.cli.command_modules.appservice.custom._poll_deployment_runtime_status') + @mock.patch('azure.cli.command_modules.appservice.custom._build_deploymentstatus_url') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom._get_url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body') + @mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers') + def test_src_url_sync_deployment_explicit_false(self, headers_mock, status_url_mock, build_url_mock_onedeploy, + body_mock, get_url_mock, send_raw_mock, build_url_mock, poll_mock): + """Test that --src-url with --async false triggers polling""" + from azure.cli.command_modules.appservice.custom import _make_onedeploy_request + + # Mock helper functions + body_mock.return_value = ('{"type": "zip"}', None) + build_url_mock_onedeploy.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy' + status_url_mock.return_value = 'https://myapp.scm.azurewebsites.net/api/deployments/latest' + headers_mock.return_value = {'Content-Type': 'application/json'} + + # Mock the ARM response with deployment ID in properties + class MockResponse: + status_code = 200 + headers = {'content-type': 'application/json'} + text = '{"properties": {"deploymentId": "dep-789"}}' + + def json(self): + return { + 'properties': {'deploymentId': 'dep-789', 'provisioningState': 'InProgress'} + } + + send_raw_mock.return_value = MockResponse() + build_url_mock.return_value = 'https://management.azure.com/.../deploymentStatus/dep-789' + poll_mock.return_value = {'status': 'RuntimeSuccessful'} + get_url_mock.return_value = 'https://myapp.azurewebsites.net' + + # Create params object with explicit async=false + class Params: + src_url = 'https://example.com/myapp.zip' + src_path = None + is_async_deployment = False # Explicitly set to False + cmd = _get_test_cmd() + resource_group_name = 'test-rg' + webapp_name = 'test-app' + slot = None + timeout = None + is_linux_webapp = False + is_functionapp = False + enable_kudu_warmup = False + + params = Params() + + # Execute + result = _make_onedeploy_request(params) + + # Assert polling was called + poll_mock.assert_called_once() + build_url_mock.assert_called_with(params.cmd, 'test-rg', 'test-app', None, 'dep-789') + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom._get_url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body') + @mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers') + def test_src_url_async_deployment(self, headers_mock, status_url_mock, build_url_mock_onedeploy, + body_mock, get_url_mock, send_raw_mock): + """Test that --src-url with --async true returns immediately without polling""" + from azure.cli.command_modules.appservice.custom import _make_onedeploy_request + + # Mock helper functions + body_mock.return_value = ('{"type": "zip"}', None) + build_url_mock_onedeploy.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy' + status_url_mock.return_value = 'https://myapp.scm.azurewebsites.net/api/deployments/latest' + headers_mock.return_value = {'Content-Type': 'application/json'} + + # Mock the ARM response + class MockResponse: + status_code = 200 + headers = {'content-type': 'application/json'} + text = '{"id": "/subscriptions/sub/.../123456"}' + + def json(self): + return { + 'id': '/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy/123456', + 'properties': {'provisioningState': 'InProgress'} + } + + send_raw_mock.return_value = MockResponse() + get_url_mock.return_value = 'https://myapp.azurewebsites.net' + + # Create params object with async=true + class Params: + src_url = 'https://example.com/myapp.zip' + src_path = None + is_async_deployment = True # Async mode + cmd = _get_test_cmd() + resource_group_name = 'test-rg' + webapp_name = 'test-app' + slot = None + timeout = None + is_linux_webapp = False + is_functionapp = False + enable_kudu_warmup = False + + params = Params() + + # Execute + result = _make_onedeploy_request(params) + + # Assert result is immediate response (provisioningState returned) + self.assertEqual(result.get('provisioningState'), 'InProgress') + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom._get_url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body') + @mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url') + @mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers') + def test_src_url_no_deployment_id(self, headers_mock, status_url_mock, build_url_mock_onedeploy, + body_mock, get_url_mock, send_raw_mock): + """Test that --src-url falls back gracefully when no deployment ID is found""" + from azure.cli.command_modules.appservice.custom import _make_onedeploy_request + + # Mock helper functions + body_mock.return_value = ('{"type": "zip"}', None) + build_url_mock_onedeploy.return_value = 'https://management.azure.com/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/sites/myapp/extensions/onedeploy' + status_url_mock.return_value = 'https://myapp.scm.azurewebsites.net/api/deployments/latest' + headers_mock.return_value = {'Content-Type': 'application/json'} + + # Mock the ARM response without deployment ID + class MockResponse: + status_code = 200 + headers = {'content-type': 'application/json'} + text = '{}' + + def json(self): + return { + 'properties': {'provisioningState': 'Succeeded'} + } + + send_raw_mock.return_value = MockResponse() + get_url_mock.return_value = 'https://myapp.azurewebsites.net' + + # Create params object + class Params: + src_url = 'https://example.com/myapp.zip' + src_path = None + is_async_deployment = None # Default + cmd = _get_test_cmd() + resource_group_name = 'test-rg' + webapp_name = 'test-app' + slot = None + timeout = None + is_linux_webapp = False + is_functionapp = False + enable_kudu_warmup = False + + params = Params() + + # Execute + result = _make_onedeploy_request(params) + + # Assert result is immediate response (no polling) + self.assertEqual(result.get('provisioningState'), 'Succeeded') + + + @mock.patch('azure.cli.command_modules.appservice.custom._poll_deployment_runtime_status', + side_effect=RuntimeError("Simulated polling failure")) + @mock.patch('azure.cli.command_modules.appservice.custom._build_deploymentstatus_url', + return_value='https://management.azure.com/.../deploymentStatus/abc123') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom._get_url', + return_value='https://test-app.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_request_body', + return_value=('{"type": "zip"}', None)) + @mock.patch('azure.cli.command_modules.appservice.custom._build_onedeploy_url', + return_value='https://management.azure.com/.../onedeploy') + @mock.patch('azure.cli.command_modules.appservice.custom._get_onedeploy_status_url', + return_value='https://myapp.scm.azurewebsites.net/api/deployments/latest') + @mock.patch('azure.cli.command_modules.appservice.custom._get_ondeploy_headers', + return_value={'Content-Type': 'application/json'}) + def test_src_url_fallback_on_poll_exception(self, headers_mock, status_url_mock, + build_url_mock_onedeploy, body_mock, + get_url_mock, send_raw_mock, + build_status_url_mock, poll_mock): + """Test that when _poll_deployment_runtime_status raises, the fallback returns the ARM response.""" + from azure.cli.command_modules.appservice.custom import _make_onedeploy_request + + class MockResponse: + status_code = 200 + headers = {'content-type': 'application/json'} + text = '{"id": "/subs/sub/rg/rg/providers/Microsoft.Web/sites/app/extensions/onedeploy/abc123"}' + + def json(self): + return { + 'id': '/subs/sub/rg/rg/providers/Microsoft.Web/sites/app/extensions/onedeploy/abc123', + 'properties': {'provisioningState': 'InProgress', 'deployer': 'ZipDeploy'} + } + + send_raw_mock.return_value = MockResponse() + + class Params: + src_url = 'https://example.com/myapp.zip' + src_path = None + is_async_deployment = None # sync mode + cmd = _get_test_cmd() + resource_group_name = 'test-rg' + webapp_name = 'test-app' + slot = None + timeout = None + is_linux_webapp = False + is_functionapp = False + enable_kudu_warmup = False + + params = Params() + result = _make_onedeploy_request(params) + + # Polling was attempted and raised + poll_mock.assert_called_once() + # Fallback: ARM response properties are returned instead of crashing + self.assertIsNotNone(result) + self.assertEqual(result.get('provisioningState'), 'InProgress') + self.assertEqual(result.get('deployer'), 'ZipDeploy') + if __name__ == '__main__': unittest.main()