diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index 5b7b5b1da03..db850c7b64c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -2365,6 +2365,39 @@ text: az webapp log deployment list --name MyWebApp --resource-group MyResourceGroup """ +helps['webapp log startup'] = """ +type: group +short-summary: View web app container startup logs. +long-summary: > + View startup logs written during container initialization for Linux web apps. + Use when a container fails to start, crashes on cold start, times out waiting + for a port, or returns HTTP 502/503 errors after deployment. These logs contain + platform lifecycle events and container stdout/stderr output. +""" + +helps['webapp log startup list'] = """ +type: command +short-summary: List all container startup log files for a web app. +examples: + - name: List all startup log files + text: az webapp log startup list --name MyWebApp --resource-group MyResourceGroup + - name: List only failure logs + text: az webapp log startup list --name MyWebApp --resource-group MyResourceGroup --outcome failure +""" + +helps['webapp log startup show'] = """ +type: command +short-summary: Show the content of a container startup log. +long-summary: By default, shows the most recent startup log, preferring failure logs. Use --filename to view a specific log file, or --instance to scope to a specific worker. +examples: + - name: Show the latest startup log (prefers failures) + text: az webapp log startup show --name MyWebApp --resource-group MyResourceGroup + - name: Show a specific startup log file + text: az webapp log startup show --name MyWebApp --resource-group MyResourceGroup --filename 2026_04_13_lw0sdlwk000002_failure.log + - name: Show the latest startup log for a specific worker instance + text: az webapp log startup show --name MyWebApp --resource-group MyResourceGroup --instance lw0sdlwk000002 +""" + helps['functionapp log'] = """ type: group short-summary: Manage function app logs. diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index 782fb8cb67c..973620091b8 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -780,6 +780,19 @@ def load_arguments(self, _): c.argument('resource_group', arg_type=resource_group_name_type) c.argument('slot', options_list=['--slot', '-s'], help="the name of the slot. Default to the productions slot if not specified") + with self.argument_context('webapp log startup') as c: + c.argument('name', arg_type=webapp_name_arg_type, id_part=None) + c.argument('resource_group', arg_type=resource_group_name_type) + c.argument('slot', options_list=['--slot', '-s'], help="the name of the slot. Default to the production slot if not specified") + c.argument('instance', options_list=['--instance'], help='Filter by worker instance name.') + + with self.argument_context('webapp log startup list') as c: + c.argument('outcome', options_list=['--outcome'], help='Filter by startup outcome.', + arg_type=get_enum_type(['success', 'failure'])) + + with self.argument_context('webapp log startup show') as c: + c.argument('filename', options_list=['--filename', '-f'], help='Name of a specific startup log file to display. If not specified, shows the latest log (preferring failures).') + with self.argument_context('functionapp log deployment show') as c: c.argument('name', arg_type=functionapp_name_arg_type, id_part=None) c.argument('resource_group', arg_type=resource_group_name_type) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index 47536f5a1df..0aa9722cb04 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -246,6 +246,10 @@ def load_command_table(self, _): g.custom_show_command('show', 'show_deployment_log') g.custom_command('list', 'list_deployment_logs') + with self.command_group('webapp log startup', is_preview=True) as g: + g.custom_command('list', 'list_startup_logs') + g.custom_show_command('show', 'show_startup_log') + with self.command_group('functionapp log deployment') as g: g.custom_show_command('show', 'show_deployment_log') g.custom_command('list', 'list_deployment_logs') 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 df80810161b..357061fd932 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -5597,6 +5597,78 @@ def list_deployment_logs(cmd, resource_group, name, slot=None): return response.json() or [] +def list_startup_logs(cmd, resource_group, name, slot=None, outcome=None, instance=None): + import requests + + scm_url = _get_scm_url(cmd, resource_group, name, slot) + headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group, slot) + + params = {} + if outcome: + params['type'] = outcome + if instance: + params['instance'] = instance + + url = '{}/api/startuplogs'.format(scm_url) + response = requests.get(url, headers=headers, params=params) + + if response.status_code == 404: + logger.warning( + 'Startup logs are not available for this app. ' + 'This feature requires a platform version that may not have rolled out to your app\'s region yet.') + return [] + if response.status_code != 200: + raise CLIError("Failed to retrieve startup logs from '{}' with status code '{}' and reason '{}'".format( + url, response.status_code, response.reason)) + + result = response.json() + return result.get('files', result) if isinstance(result, dict) else result + + +def show_startup_log(cmd, resource_group, name, slot=None, filename=None, instance=None): + import requests + + scm_url = _get_scm_url(cmd, resource_group, name, slot) + headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group, slot) + + if filename: + url = '{}/api/startuplogs/{}'.format(scm_url, quote(filename, safe='')) + else: + url = '{}/api/startuplogs?latest=true'.format(scm_url) + if instance: + url += '&instance={}'.format(quote(instance, safe='')) + + response = requests.get(url, headers=headers) + + if response.status_code == 404: + if filename: + logger.warning('Startup log file \'%s\' was not found.', filename) + else: + logger.warning( + 'Startup logs are not available for this app. ' + 'This feature requires a platform version that may not have rolled out to your app\'s region yet.') + return None + if response.status_code != 200: + raise CLIError("Failed to retrieve startup log from '{}' with status code '{}' and reason '{}'".format( + url, response.status_code, response.reason)) + + content_type = response.headers.get('Content-Type', '') + if 'text/plain' in content_type: + # Raw log content — return metadata from headers along with content + log_content = response.text + metadata = {} + for header_name in ['X-StartupLog-Filename', 'X-StartupLog-Date', 'X-StartupLog-Instance', + 'X-StartupLog-Outcome']: + value = response.headers.get(header_name) + if value: + key = header_name.replace('X-StartupLog-', '').lower() + metadata[key] = value + metadata['content'] = log_content + return metadata + + return response.json() + + def config_slot_auto_swap(cmd, resource_group_name, webapp, slot, auto_swap_slot=None, disable=None): client = web_client_factory(cmd.cli_ctx) site_config = client.web_apps.get_configuration_slot(resource_group_name, webapp, slot) @@ -8521,6 +8593,11 @@ def _poll_deployment_runtime_status(cmd, resource_group_name, webapp_name, slot, if failure_logs is not None and len(failure_logs) > 0: failure_logs = failure_logs[0] error_text += "Please check the runtime logs for more info: {}\n".format(failure_logs) + tip_cmd = "az webapp log startup show -n {} -g {}".format(webapp_name, resource_group_name) + if slot: + tip_cmd += " --slot {}".format(slot) + error_text += ("TIP: Run '{}' " + "to view container startup logs.\n").format(tip_cmd) if site_started_partially: logger.warning(error_text) break @@ -8565,6 +8642,11 @@ def _poll_deployment_runtime_status(cmd, resource_group_name, webapp_name, slot, deployment_properties.get('numberOfInstancesInProgress'), deployment_properties.get('numberOfInstancesSuccessful'), deployment_properties.get('numberOfInstancesFailed')) + tip_cmd = "az webapp log startup show -n {} -g {}".format(webapp_name, resource_group_name) + if slot: + tip_cmd += " --slot {}".format(slot) + error_text += ("\nTIP: Run '{}' " + "to view container startup logs.").format(tip_cmd) raise CLIError(error_text) 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..64e53a6f892 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 @@ -33,7 +33,9 @@ add_github_actions, update_app_settings, update_application_settings_polling, - update_webapp) + update_webapp, + list_startup_logs, + show_startup_log) # pylint: disable=line-too-long from azure.cli.core.profiles import ResourceType @@ -639,6 +641,295 @@ def test_update_webapp_platform_release_channel_latest(self): self.assertEqual(result.additional_properties["properties"]["platformReleaseChannel"], "Latest") +class TestStartupLogsMocked(unittest.TestCase): + """Tests for az webapp log startup list/show commands.""" + + def _make_response(self, status_code=200, json_data=None, text='', headers=None, reason=''): + resp = mock.MagicMock() + resp.status_code = status_code + resp.reason = reason + resp.text = text + resp.headers = headers or {} + resp.json.return_value = json_data + return resp + + # ---- list_startup_logs ---- + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_list_startup_logs_success(self, requests_get_mock, _scm_url_mock, _headers_mock): + files = [ + {'Filename': '2026_04_13_lw0sdlwk000002_success.log', 'Href': '/api/startuplogs/...'}, + {'Filename': '2026_04_13_lw0sdlwk000003_failure.log', 'Href': '/api/startuplogs/...'}, + ] + requests_get_mock.return_value = self._make_response(200, json_data={'files': files}) + + result = list_startup_logs(_get_test_cmd(), 'myRG', 'myApp') + + self.assertEqual(result, files) + requests_get_mock.assert_called_once_with( + 'https://myapp.scm.azurewebsites.net/api/startuplogs', + headers={'Authorization': 'Bearer token'}, + params={} + ) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_list_startup_logs_with_filters(self, requests_get_mock, _scm_url_mock, _headers_mock): + requests_get_mock.return_value = self._make_response(200, json_data={'files': []}) + + list_startup_logs(_get_test_cmd(), 'myRG', 'myApp', outcome='failure', instance='lw0sdlwk000002') + + requests_get_mock.assert_called_once_with( + 'https://myapp.scm.azurewebsites.net/api/startuplogs', + headers={'Authorization': 'Bearer token'}, + params={'type': 'failure', 'instance': 'lw0sdlwk000002'} + ) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_list_startup_logs_404_graceful(self, requests_get_mock, _scm_url_mock, _headers_mock): + requests_get_mock.return_value = self._make_response(404) + + with mock.patch('azure.cli.command_modules.appservice.custom.logger') as logger_mock: + result = list_startup_logs(_get_test_cmd(), 'myRG', 'myApp') + + self.assertEqual(result, []) + logger_mock.warning.assert_called_once() + self.assertIn('platform version', logger_mock.warning.call_args[0][0]) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_list_startup_logs_500_raises(self, requests_get_mock, _scm_url_mock, _headers_mock): + requests_get_mock.return_value = self._make_response(500, reason='Internal Server Error') + + with self.assertRaises(CLIError) as cm: + list_startup_logs(_get_test_cmd(), 'myRG', 'myApp') + self.assertIn('500', str(cm.exception)) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_list_startup_logs_with_slot(self, requests_get_mock, scm_url_mock, _headers_mock): + requests_get_mock.return_value = self._make_response(200, json_data={'files': []}) + + list_startup_logs(_get_test_cmd(), 'myRG', 'myApp', slot='staging') + + scm_url_mock.assert_called_once_with(mock.ANY, 'myRG', 'myApp', 'staging') + + # ---- show_startup_log ---- + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_show_startup_log_latest(self, requests_get_mock, _scm_url_mock, _headers_mock): + log_text = 'Container started successfully.\nListening on port 8080.' + requests_get_mock.return_value = self._make_response( + 200, text=log_text, + headers={ + 'Content-Type': 'text/plain', + 'X-StartupLog-Filename': '2026_04_13_lw0_success.log', + 'X-StartupLog-Date': '2026-04-13T10:00:00Z', + 'X-StartupLog-Instance': 'lw0sdlwk000002', + 'X-StartupLog-Outcome': 'success', + } + ) + + result = show_startup_log(_get_test_cmd(), 'myRG', 'myApp') + + self.assertEqual(result['content'], log_text) + self.assertEqual(result['filename'], '2026_04_13_lw0_success.log') + self.assertEqual(result['instance'], 'lw0sdlwk000002') + self.assertEqual(result['outcome'], 'success') + requests_get_mock.assert_called_once_with( + 'https://myapp.scm.azurewebsites.net/api/startuplogs?latest=true', + headers={'Authorization': 'Bearer token'} + ) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_show_startup_log_specific_filename(self, requests_get_mock, _scm_url_mock, _headers_mock): + requests_get_mock.return_value = self._make_response( + 200, text='log content', + headers={'Content-Type': 'text/plain'} + ) + + show_startup_log(_get_test_cmd(), 'myRG', 'myApp', filename='2026_04_13_lw0_success.log') + + requests_get_mock.assert_called_once_with( + 'https://myapp.scm.azurewebsites.net/api/startuplogs/2026_04_13_lw0_success.log', + headers={'Authorization': 'Bearer token'} + ) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_show_startup_log_with_instance(self, requests_get_mock, _scm_url_mock, _headers_mock): + requests_get_mock.return_value = self._make_response( + 200, text='instance log content', + headers={ + 'Content-Type': 'text/plain', + 'X-StartupLog-Filename': '2026_04_13_lw0sdlwk000002_failure.log', + 'X-StartupLog-Instance': 'lw0sdlwk000002', + 'X-StartupLog-Outcome': 'failure', + } + ) + + result = show_startup_log(_get_test_cmd(), 'myRG', 'myApp', instance='lw0sdlwk000002') + + self.assertEqual(result['content'], 'instance log content') + self.assertEqual(result['instance'], 'lw0sdlwk000002') + requests_get_mock.assert_called_once_with( + 'https://myapp.scm.azurewebsites.net/api/startuplogs?latest=true&instance=lw0sdlwk000002', + headers={'Authorization': 'Bearer token'} + ) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_show_startup_log_404_no_filename(self, requests_get_mock, _scm_url_mock, _headers_mock): + requests_get_mock.return_value = self._make_response(404) + + with mock.patch('azure.cli.command_modules.appservice.custom.logger') as logger_mock: + result = show_startup_log(_get_test_cmd(), 'myRG', 'myApp') + + self.assertIsNone(result) + self.assertIn('platform version', logger_mock.warning.call_args[0][0]) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_show_startup_log_404_with_filename(self, requests_get_mock, _scm_url_mock, _headers_mock): + requests_get_mock.return_value = self._make_response(404) + + with mock.patch('azure.cli.command_modules.appservice.custom.logger') as logger_mock: + result = show_startup_log(_get_test_cmd(), 'myRG', 'myApp', filename='nonexistent.log') + + self.assertIsNone(result) + self.assertIn('nonexistent.log', logger_mock.warning.call_args[0][1]) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_show_startup_log_500_raises(self, requests_get_mock, _scm_url_mock, _headers_mock): + requests_get_mock.return_value = self._make_response(500, reason='Internal Server Error') + + with self.assertRaises(CLIError) as cm: + show_startup_log(_get_test_cmd(), 'myRG', 'myApp') + self.assertIn('500', str(cm.exception)) + + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': 'Bearer token'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_show_startup_log_json_response(self, requests_get_mock, _scm_url_mock, _headers_mock): + json_data = {'filename': 'test.log', 'content': 'data'} + requests_get_mock.return_value = self._make_response( + 200, json_data=json_data, + headers={'Content-Type': 'application/json'} + ) + + result = show_startup_log(_get_test_cmd(), 'myRG', 'myApp') + + self.assertEqual(result, json_data) + + +class TestRuntimeFailedHintMocked(unittest.TestCase): + """Tests that the TIP hint appears in RuntimeFailed and timeout errors.""" + + def _make_deployment_response(self, status, num_in_progress=0, num_successful=0, + num_failed=1, errors=None, failure_logs=None): + return { + 'properties': { + 'status': status, + 'numberOfInstancesInProgress': str(num_in_progress), + 'numberOfInstancesSuccessful': str(num_successful), + 'numberOfInstancesFailed': str(num_failed), + 'errors': errors or [], + 'failedInstancesLogs': failure_logs, + } + } + + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.time.sleep') + @mock.patch('azure.cli.command_modules.appservice.custom.time.time') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + def test_runtime_failed_includes_startup_log_hint(self, send_raw_mock, time_mock, + sleep_mock, _scm_url_mock): + from azure.cli.command_modules.appservice.custom import _poll_deployment_runtime_status + + time_mock.return_value = 10 # constant — never times out, RuntimeFailed triggers on first iteration + resp_mock = mock.MagicMock() + resp_mock.json.return_value = self._make_deployment_response('RuntimeFailed') + send_raw_mock.return_value = resp_mock + + with self.assertRaises(CLIError) as cm: + _poll_deployment_runtime_status( + _get_test_cmd(), 'myRG', 'myApp', None, + 'https://management.azure.com/deploymentstatus', 'deploy-id-1' + ) + + error_msg = str(cm.exception) + self.assertIn('az webapp log startup show -n myApp -g myRG', error_msg) + self.assertIn('failed to start', error_msg) + + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('azure.cli.command_modules.appservice.custom.time.sleep') + @mock.patch('azure.cli.command_modules.appservice.custom.time.time') + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + def test_timeout_includes_startup_log_hint(self, send_raw_mock, time_mock, + sleep_mock, _scm_url_mock): + from azure.cli.command_modules.appservice.custom import _poll_deployment_runtime_status + import itertools + + # Advancing counter: each call returns 0, 1, 2, ... — exceeds timeout=1 after first loop + time_mock.side_effect = itertools.count(0) + resp_mock = mock.MagicMock() + resp_mock.json.return_value = self._make_deployment_response('RuntimeStarting') + send_raw_mock.return_value = resp_mock + + with self.assertRaises(CLIError) as cm: + _poll_deployment_runtime_status( + _get_test_cmd(), 'myRG', 'myApp', None, + 'https://management.azure.com/deploymentstatus', 'deploy-id-1', + timeout=1 + ) + + error_msg = str(cm.exception) + self.assertIn('az webapp log startup show -n myApp -g myRG', error_msg) + self.assertIn('Timeout', error_msg) + + class FakedResponse: # pylint: disable=too-few-public-methods def __init__(self, status_code): self.status_code = status_code