From 278da15c9501bc736450382311aeed6544e6e967 Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Wed, 25 Mar 2026 20:42:29 -0400 Subject: [PATCH 1/4] [App Service] Fix #29290: Improve error messages for az webapp deploy --src-url failures When `az webapp deploy --src-url` receives non-2xx HTTP responses, `send_raw_request` raises `HTTPError` before the deploy-specific error handling code is reached. This results in bare error messages like "Bad Request" with no actionable guidance. This change adds a `_send_deploy_request` wrapper that catches HTTP errors on the --src-url ARM deploy path and provides clear, actionable messages: - 400 Bad Request: troubleshooting guidance for URL accessibility, SAS tokens, and artifact type mismatches - 404 Not Found: guidance to verify resource group, app name, and slot - 409 Conflict: message about in-progress deployments The wrapper uses the server-provided reason phrase and includes any response body details when available. Unhandled status codes re-raise the original HTTPError unchanged. Fixes #29290 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 8 ++ .../cli/command_modules/appservice/custom.py | 45 ++++++- .../latest/test_webapp_commands_thru_mock.py | 119 +++++++++++++++++- 3 files changed, 166 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 28b786e6297..e887d46d81c 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,11 @@ cmd_coverage/* # Ignore test results test_results.xml + +# Squad agent framework (local only — never commit) +.squad/ +.squad-workstream +.github/agents/squad.agent.md +.github/workflows/squad-*.yml +.github/workflows/sync-squad-labels.yml +.copilot/ 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..789480f704c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -9781,6 +9781,43 @@ def _warmup_kudu_and_get_cookie_internal(params): return None +def _send_deploy_request(cli_ctx, deploy_url, body): + """Wrapper around send_raw_request for --src-url deployments that provides + actionable error messages instead of bare HTTP status codes.""" + from azure.cli.core.azclierror import HTTPError + try: + return send_raw_request(cli_ctx, "PUT", deploy_url, body=body) + except HTTPError as ex: + resp = ex.response + status_code = resp.status_code if resp is not None else None + response_text = resp.text if resp is not None else "" + reason = getattr(resp, 'reason', None) or "Unknown" + error_detail = f" Details: {response_text}" if response_text else "" + if status_code == 400: + raise CLIError( + f"Deployment from URL failed with status 400 ({reason}).{error_detail}\n" + "Possible causes:\n" + " - The source URL is not publicly accessible or the SAS token has expired\n" + " - The URL does not point to a valid deployment artifact\n" + " - The artifact type does not match the file content (e.g., --type zip for a non-zip file)\n" + "Please verify the URL is accessible and the artifact type is correct." + ) from ex + if status_code == 404: + raise ResourceNotFoundError( + f"Deployment from URL failed with status 404 ({reason}).{error_detail}\n" + "The target app or OneDeploy endpoint could not be found. " + "Verify that the resource group, app name, and slot (if any) are correct, " + "and that the app still exists." + ) from ex + if status_code == 409: + raise ValidationError( + f"Deployment from URL failed with status 409 ({reason}).{error_detail}\n" + "Another deployment is currently in progress. Please wait for the existing " + "deployment to complete before starting a new one." + ) from ex + raise + + def _make_onedeploy_request(params): import requests from azure.cli.core.util import should_disable_connection_verify @@ -9828,16 +9865,16 @@ def _make_onedeploy_request(params): if cookies is None: logger.info("Failed to fetch affinity cookie for Kudu. " "Deployment will proceed without pre-warming a Kudu instance.") - response = send_raw_request(params.cmd.cli_ctx, "PUT", deploy_url, body=body) + response = _send_deploy_request(params.cmd.cli_ctx, deploy_url, body) else: deploy_arm_url = _build_onedeploy_url(params, cookies.get("ARRAffinity")) - response = send_raw_request(params.cmd.cli_ctx, "PUT", deploy_arm_url, body=body) + response = _send_deploy_request(params.cmd.cli_ctx, deploy_arm_url, body) except Exception as ex: # pylint: disable=broad-except logger.info("Failed to deploy using instances endpoint. " "Deployment will proceed without pre-warming a Kudu instance. Ex: %s", ex) - response = send_raw_request(params.cmd.cli_ctx, "PUT", deploy_url, body=body) + response = _send_deploy_request(params.cmd.cli_ctx, deploy_url, body) else: - response = send_raw_request(params.cmd.cli_ctx, "PUT", deploy_url, body=body) + response = _send_deploy_request(params.cmd.cli_ctx, deploy_url, body) poll_async_deployment_for_debugging = False # check the status of deployment 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..d6a5d72ae6a 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 @@ -12,7 +12,9 @@ from knack.util import CLIError from azure.cli.core.azclierror import (InvalidArgumentValueError, MutuallyExclusiveArgumentError, - AzureResponseError) + AzureResponseError, + ResourceNotFoundError, + ValidationError) from azure.cli.command_modules.appservice.custom import (set_deployment_user, update_git_token, add_hostname, update_site_configs, @@ -33,7 +35,8 @@ add_github_actions, update_app_settings, update_application_settings_polling, - update_webapp) + update_webapp, + _send_deploy_request) # pylint: disable=line-too-long from azure.cli.core.profiles import ResourceType @@ -639,6 +642,118 @@ def test_update_webapp_platform_release_channel_latest(self): self.assertEqual(result.additional_properties["properties"]["platformReleaseChannel"], "Latest") +class TestSendDeployRequest(unittest.TestCase): + """Tests for _send_deploy_request wrapper that provides actionable error messages.""" + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + def test_success_passes_through(self, send_raw_request_mock): + """Successful responses (200/202) should pass through unchanged.""" + cli_ctx = _get_test_cmd().cli_ctx + response = mock.MagicMock() + response.status_code = 202 + send_raw_request_mock.return_value = response + + result = _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}') + + self.assertEqual(result, response) + send_raw_request_mock.assert_called_once_with( + cli_ctx, "PUT", 'https://management.azure.com/deploy', body='{"properties":{}}' + ) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + def test_400_with_empty_body_gives_actionable_error(self, send_raw_request_mock): + """HTTP 400 with empty body should produce a helpful error message instead of bare 'Bad Request'.""" + from azure.cli.core.azclierror import HTTPError + cli_ctx = _get_test_cmd().cli_ctx + + response = mock.MagicMock() + response.status_code = 400 + response.reason = "Bad Request" + response.text = "" + send_raw_request_mock.side_effect = HTTPError("Bad Request", response) + + with self.assertRaises(CLIError) as ctx: + _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}') + + error_msg = str(ctx.exception) + self.assertIn("Deployment from URL failed with status 400 (Bad Request)", error_msg) + self.assertIn("source URL is not publicly accessible", error_msg) + self.assertIn("SAS token has expired", error_msg) + self.assertIn("artifact type does not match", error_msg) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + def test_400_with_response_body_includes_details(self, send_raw_request_mock): + """HTTP 400 with a response body should include the details in the error.""" + from azure.cli.core.azclierror import HTTPError + cli_ctx = _get_test_cmd().cli_ctx + + response = mock.MagicMock() + response.status_code = 400 + response.reason = "Bad Request" + response.text = "Invalid package URI" + send_raw_request_mock.side_effect = HTTPError("Bad Request(Invalid package URI)", response) + + with self.assertRaises(CLIError) as ctx: + _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}') + + error_msg = str(ctx.exception) + self.assertIn("Deployment from URL failed with status 400", error_msg) + self.assertIn("Invalid package URI", error_msg) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + def test_404_gives_not_found_error(self, send_raw_request_mock): + """HTTP 404 should raise ResourceNotFoundError with guidance.""" + from azure.cli.core.azclierror import HTTPError + cli_ctx = _get_test_cmd().cli_ctx + + response = mock.MagicMock() + response.status_code = 404 + response.reason = "Not Found" + response.text = "" + send_raw_request_mock.side_effect = HTTPError("Not Found", response) + + with self.assertRaises(ResourceNotFoundError) as ctx: + _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}') + + error_msg = str(ctx.exception) + self.assertIn("Deployment from URL failed with status 404", error_msg) + self.assertIn("app or OneDeploy endpoint could not be found", error_msg) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + def test_409_gives_conflict_error(self, send_raw_request_mock): + """HTTP 409 should raise ValidationError about in-progress deployment.""" + from azure.cli.core.azclierror import HTTPError + cli_ctx = _get_test_cmd().cli_ctx + + response = mock.MagicMock() + response.status_code = 409 + response.reason = "Conflict" + response.text = "" + send_raw_request_mock.side_effect = HTTPError("Conflict", response) + + with self.assertRaises(ValidationError) as ctx: + _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}') + + error_msg = str(ctx.exception) + self.assertIn("Deployment from URL failed with status 409", error_msg) + self.assertIn("Another deployment is currently in progress", error_msg) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + def test_unhandled_error_reraises(self, send_raw_request_mock): + """Unhandled HTTP errors (e.g., 500) should re-raise without modification.""" + from azure.cli.core.azclierror import HTTPError + cli_ctx = _get_test_cmd().cli_ctx + + response = mock.MagicMock() + response.status_code = 500 + response.reason = "Internal Server Error" + response.text = "Internal Server Error" + send_raw_request_mock.side_effect = HTTPError("Internal Server Error", response) + + with self.assertRaises(HTTPError): + _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}') + + class FakedResponse: # pylint: disable=too-few-public-methods def __init__(self, status_code): self.status_code = status_code From 920b114634baf5b100e8333e01cd1de931cec8a7 Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Wed, 25 Mar 2026 22:25:21 -0400 Subject: [PATCH 2/4] Retrigger CI after title fix From e8ed21b9622e873463c81636de4d53a79f2850f7 Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Thu, 26 Mar 2026 10:23:27 -0400 Subject: [PATCH 3/4] ci: Retrigger CI after PR title format fix Wrapped `--src-url` in backticks in PR title to pass format checker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From c2fa6d7f43afa5da1afdcb1bb4db56d329f4825e Mon Sep 17 00:00:00 2001 From: Jordan Selig Date: Wed, 25 Mar 2026 21:32:49 -0400 Subject: [PATCH 4/4] [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> --- .../cli/command_modules/appservice/custom.py | 58 +++- .../latest/test_webapp_commands_thru_mock.py | 287 ++++++++++++++++++ 2 files changed, 340 insertions(+), 5 deletions(-) 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 789480f704c..351743deadd 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -9893,12 +9893,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 d6a5d72ae6a..6b338e872a4 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 @@ -759,5 +759,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()