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 5da17f08f94..b0390313934 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -9652,6 +9652,12 @@ def _check_zip_deployment_status_flex(cmd, rg_name, name, deployment_status_url, # Indicates whether the status has been non empty in previous calls has_response = False has_partial_success = False + # Indicates Kudu is restarting after package deployment (transient state for flex consumption apps) + kudu_restart_in_progress = False + # Tracks whether the Kudu-restart 404 warning has already been emitted once + kudu_restart_404_warned = False + # Default return value; overwritten on each successful JSON response + res_dict = {} while num_trials < total_trials: time.sleep(1) response = requests.get(deployment_status_url, headers=headers, @@ -9659,14 +9665,27 @@ def _check_zip_deployment_status_flex(cmd, rg_name, name, deployment_status_url, try: if response.status_code == 202 and not has_partial_success: has_partial_success = True - if response.status_code == 404 and has_partial_success: - break - if (response.status_code == 404 or response.json().get('status') is None) and has_response: - raise CLIError("Failed to retrieve deployment status. Please try again in a few minutes.") - if (response.status_code != 404 and response.json().get('status') is not None) and not has_response: - has_response = True - - res_dict = response.json() + if response.status_code == 404: + if has_partial_success and not kudu_restart_in_progress: + break + elif kudu_restart_in_progress: + # Kudu is restarting after package deployment — continue polling until it comes back + if not kudu_restart_404_warned: + kudu_restart_404_warned = True + logger.warning("Deployment status endpoint returned 404. " + "Kudu may be restarting after package deployment. Retrying...") + else: + logger.info("Deployment status endpoint still returning 404 during Kudu restart. Retrying...") + elif has_response: + raise CLIError("Failed to retrieve deployment status. Please try again in a few minutes.") + else: + res_json = response.json() + res_status = res_json.get('status') + if res_status is None and has_response: + raise CLIError("Failed to retrieve deployment status. Please try again in a few minutes.") + if res_status is not None and not has_response: + has_response = True + res_dict = res_json except json.decoder.JSONDecodeError: logger.warning("Deployment status endpoint %s returns malformed data. Retrying...", deployment_status_url) res_dict = {} @@ -9685,12 +9704,14 @@ def _check_zip_deployment_status_flex(cmd, rg_name, name, deployment_status_url, if status == 5: raise CLIError("Deployment was cancelled and another deployment is in progress.") if status == 6: - raise CLIError("Deployment was partially successful. These are the deployment logs:\n{}".format( - json.dumps(show_deployment_log(cmd, rg_name, name)))) + if not kudu_restart_in_progress: + kudu_restart_in_progress = True + logger.warning("Deployment is partially complete. Kudu may be restarting after package " + "deployment. Continuing to poll for completion...") if 'progress' in res_dict: logger.info(res_dict['progress']) # show only in debug mode, customers seem to find this confusing # if the deployment is taking longer than expected - if res_dict.get('status', 0) != 4 and not has_partial_success: + if res_dict.get('status', 0) != 4 and not has_partial_success and not kudu_restart_in_progress: raise CLIError("""Timeout reached by the command, however, the deployment operation is still on-going. Navigate to your scm site to check the deployment status""") return res_dict diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py index 9f766ab8e2b..407c5165732 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_functionapp_commands_thru_mock.py @@ -12,6 +12,7 @@ enable_zip_deploy_functionapp, enable_zip_deploy, enable_zip_deploy_flex, + _check_zip_deployment_status_flex, add_remote_build_app_settings, remove_remote_build_app_settings, config_source_control, @@ -681,3 +682,99 @@ def test_config_source_control(self, # assert self.assertEqual(response.git_hub_action_configuration.container_configuration.password, None) + + +class TestCheckZipDeploymentStatusFlex(unittest.TestCase): + """Tests for _check_zip_deployment_status_flex handling of Kudu restart (status 6) scenario.""" + + def _make_response(self, status_code, json_body=None): + """Create a mock HTTP response.""" + resp = mock.MagicMock() + resp.status_code = status_code + if json_body is not None: + resp.json.return_value = json_body + else: + resp.json.side_effect = Exception("No JSON body") + return resp + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers_flex', return_value={}) + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) + @mock.patch('time.sleep', return_value=None) + @mock.patch('requests.get') + def test_status_6_then_status_4_succeeds(self, requests_get_mock, sleep_mock, + should_disable_mock, get_headers_mock): + """Status 6 (Kudu restart) followed by status 4 (success) should not raise an error.""" + cmd_mock = _get_test_cmd() + + # Simulate: first poll returns status 6 (partial success / Kudu restarting), + # second poll returns status 4 (success) + requests_get_mock.side_effect = [ + self._make_response(200, {'status': 6, 'progress': ''}), + self._make_response(200, {'status': 4, 'complete': True}), + ] + + result = _check_zip_deployment_status_flex(cmd_mock, 'rg', 'name', + 'https://mock-scm/api/deployments/latest', + timeout=None) + self.assertEqual(result.get('status'), 4) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers_flex', return_value={}) + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) + @mock.patch('time.sleep', return_value=None) + @mock.patch('requests.get') + def test_status_6_then_404_then_status_4_succeeds(self, requests_get_mock, sleep_mock, + should_disable_mock, get_headers_mock): + """Status 6 followed by 404 (Kudu restarting) followed by status 4 should succeed.""" + cmd_mock = _get_test_cmd() + + requests_get_mock.side_effect = [ + self._make_response(200, {'status': 6, 'progress': ''}), + self._make_response(404), + self._make_response(200, {'status': 4, 'complete': True}), + ] + + result = _check_zip_deployment_status_flex(cmd_mock, 'rg', 'name', + 'https://mock-scm/api/deployments/latest', + timeout=None) + self.assertEqual(result.get('status'), 4) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers_flex', return_value={}) + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) + @mock.patch('time.sleep', return_value=None) + @mock.patch('requests.get') + def test_status_6_multiple_404_then_status_4_succeeds(self, requests_get_mock, sleep_mock, + should_disable_mock, get_headers_mock): + """Multiple 404 responses during Kudu restart should continue polling until success.""" + cmd_mock = _get_test_cmd() + + requests_get_mock.side_effect = [ + self._make_response(200, {'status': 6, 'progress': ''}), + self._make_response(404), + self._make_response(404), + self._make_response(200, {'status': 4, 'complete': True}), + ] + + result = _check_zip_deployment_status_flex(cmd_mock, 'rg', 'name', + 'https://mock-scm/api/deployments/latest', + timeout=None) + self.assertEqual(result.get('status'), 4) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers_flex', return_value={}) + @mock.patch('azure.cli.core.util.should_disable_connection_verify', return_value=False) + @mock.patch('time.sleep', return_value=None) + @mock.patch('requests.get') + def test_status_3_still_raises_error(self, requests_get_mock, sleep_mock, + should_disable_mock, get_headers_mock): + """Status 3 (deployment failed) should still raise an error.""" + cmd_mock = _get_test_cmd() + + with mock.patch('azure.cli.command_modules.appservice.custom.show_deployment_log', return_value=[]): + requests_get_mock.side_effect = [ + self._make_response(200, {'status': 3}), + ] + + with self.assertRaises(CLIError) as cm: + _check_zip_deployment_status_flex(cmd_mock, 'rg', 'name', + 'https://mock-scm/api/deployments/latest', + timeout=None) + self.assertIn("Zip deployment failed", str(cm.exception))