diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_troubleshoot_config_report.py b/src/azure-cli/azure/cli/command_modules/appservice/_troubleshoot_config_report.py index ac0e0f3a49e..82fe337eba2 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_troubleshoot_config_report.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_troubleshoot_config_report.py @@ -158,9 +158,9 @@ def _render_settings_table(settings): sname='Setting', sw=setting_w, vname='Value', vw=value_w, dname='Details') _row((Style.HIGHLIGHT, header)) _row((Style.SECONDARY, '{s}{v}{d}'.format( - s=('─' * (setting_w - 2)).ljust(setting_w), - v=('─' * (value_w - 2)).ljust(value_w), - d='─' * 40))) + s=('-' * (setting_w - 2)).ljust(setting_w), + v=('-' * (value_w - 2)).ljust(value_w), + d='-' * 40))) for setting in settings: name = str(setting.get('Setting') or '') @@ -178,7 +178,7 @@ def _render_config_checks(payload, config_check, settings): if payload.get('configCheck') is not None: _render_snapshot_metadata(payload, config_check) _out() - _row((Style.HIGHLIGHT, '═══ BUILT-IN CHECKS ' + '═' * 55)) + _row((Style.HIGHLIGHT, '=== BUILT-IN CHECKS ' + '=' * 55)) _out() if payload.get('configCheck') is None: if payload.get('configCheckStatus') == 404: @@ -201,7 +201,7 @@ def _render_config_checks(payload, config_check, settings): def _render_runtime_error(runtime_error): _out() _out() - _row((Style.HIGHLIGHT, '═══ SITE RUNTIME ERROR RECOMMENDATION ' + '═' * 37)) + _row((Style.HIGHLIGHT, '=== SITE RUNTIME ERROR RECOMMENDATION ' + '=' * 37)) _out() timestamp_raw = runtime_error.get('lastErrorTimestamp') timestamp = _format_dt(timestamp_raw) or str(timestamp_raw or '') @@ -228,7 +228,7 @@ def _render_hints(payload, any_issue): slot = payload.get('slot') slot_arg = ' --slot {}'.format(slot) if slot else '' _out() - _out((Style.WARNING, '▶ Hint:')) + _out((Style.WARNING, 'Hint:')) if any_issue: _out(' Update flagged app setting: az webapp config appsettings set -n {} -g {}{} ' '--settings KEY=VALUE'.format(site_name, resource_group, slot_arg)) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_troubleshoot_status_report.py b/src/azure-cli/azure/cli/command_modules/appservice/_troubleshoot_status_report.py index 02a3ccfc276..3c83620d6ea 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_troubleshoot_status_report.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_troubleshoot_status_report.py @@ -19,6 +19,9 @@ from azure.cli.core.style import Style, print_styled_text +_MAX_ORPHAN_STARTUPS = 5 + + def _emit_header(instances, orphan_startups, app_name, emit): """Emit the top-of-report header. Returns False when there's nothing to render (empty instances AND no orphan startups) so the caller can bail out early.""" @@ -99,7 +102,7 @@ def _emit_orphan_startup(orphan, emit): container's logs, but ARM has already replaced the worker-slot ID.""" scm_id = orphan.get('InstanceId') or '' emit((Style.HIGHLIGHT, 'Instance {} Startup Summary'.format(scm_id))) - emit([(Style.HIGHLIGHT, '─' * 76)]) + emit([(Style.HIGHLIGHT, '-' * 76)]) emit((Style.HIGHLIGHT, 'Startup summary (last 24h)')) _print_startup_block(orphan.get('Startup'), emit) emit() @@ -115,7 +118,7 @@ def _emit_hint_footer(instances, app_name, resource_group, emit): if not has_error: return rg = resource_group or '' - emit((Style.WARNING, '▶ Hint:')) + emit((Style.WARNING, 'Hint:')) emit(' Check application logs: az webapp log tail -n {} -g {}'.format(app_name, rg)) emit(' Check startup logs: az webapp log startup show -n {} -g {}'.format(app_name, rg)) @@ -127,6 +130,7 @@ def render_report(payload): orphan_startups = payload.get('orphanStartups') or [] app_name = payload.get('name') or '' resource_group = payload.get('resourceGroup') + startup_summary_url = payload.get('startupSummaryUrl') def emit(*objs): print_styled_text(*objs, file=sys.stdout) @@ -140,7 +144,15 @@ def emit(*objs): for inst in instances: _emit_instance_section(inst, emit) - for orphan in orphan_startups: + if orphan_startups: + emit((Style.HIGHLIGHT, + 'Below startup records are from instances that no longer host the application ' + 'but occurred within the last 24 hours. Limited to 5 instances.')) + if startup_summary_url: + emit((Style.HIGHLIGHT, 'To review the full 24-hour history, visit {}.'.format(startup_summary_url))) + emit() + + for orphan in orphan_startups[:_MAX_ORPHAN_STARTUPS]: _emit_orphan_startup(orphan, emit) _emit_hint_footer(instances, app_name, resource_group, emit) 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 3f14d6735b0..81cb737dbef 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -177,7 +177,7 @@ def _dash(value): def _print_hint(app=app_name, rg=resource_group): _sys.stderr.write( - '\n\u25b6 Hint:\n' + '\nHint:\n' ' Check application logs: az webapp log tail -n {name} -g {rg}\n' ' Check startup logs: az webapp log startup show -n {name} -g {rg}\n' .format(name=app, rg=rg)) 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 218ed67f79f..b3b71877756 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -7737,6 +7737,9 @@ def _fetch_site_runtime_items(cmd, subscription_id, resource_group, name, slot_s return runtime_items +_MAX_ORPHAN_STARTUPS = 5 + + def _parse_startup_summary_body(body, target_machine): """Parse the JSON body returned by KuduLite /api/startuplogs/summary. Returns (startup_by_machine, plaintext_notice_or_None).""" @@ -7766,7 +7769,8 @@ def _parse_startup_summary_body(body, target_machine): def _fetch_startup_summaries(cmd, resource_group, name, slot, instance, runtime_items): """Call KuduLite /api/startuplogs/summary once for the app. When --instance is set and we have a matching runtime item, forward the machine filter so KuduLite only - walks that worker's log directory. Returns (startup_by_machine, app_wide_fetch_status).""" + walks that worker's log directory. Returns + (startup_by_machine, app_wide_fetch_status, startup_summary_url).""" import requests scm_url = _get_scm_url(cmd, resource_group, name, slot) headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group, slot) @@ -7775,7 +7779,8 @@ def _fetch_startup_summaries(cmd, resource_group, name, slot, instance, runtime_ if instance and len(runtime_items) == 1: target_machine = runtime_items[0].get('machineName') - summary_url = '{}/api/startuplogs/summary'.format(scm_url) + startup_summary_url = '{}/api/startuplogs/summary'.format(scm_url) + summary_url = startup_summary_url if target_machine: summary_url = '{}?instance={}'.format(summary_url, quote(target_machine, safe='')) @@ -7786,7 +7791,7 @@ def _fetch_startup_summaries(cmd, resource_group, name, slot, instance, runtime_ logger.warning("Failed to call '%s': %s", summary_url, ex) return startup_by_machine, ( "Failed to reach SCM startup summary endpoint ({}: {}).".format( - ex.__class__.__name__, ex)) + ex.__class__.__name__, ex)), startup_summary_url if summary_response.status_code != 200: # Common non-200s: @@ -7797,7 +7802,7 @@ def _fetch_startup_summaries(cmd, resource_group, name, slot, instance, runtime_ "Startup summary is not available for this app. " "This feature requires a platform version that has " "not rolled out to your app's region yet." - ) + ), startup_summary_url try: body = summary_response.json() @@ -7808,10 +7813,10 @@ def _fetch_startup_summaries(cmd, resource_group, name, slot, instance, runtime_ # Plaintext body (e.g. WEBSITE_DISABLE_CONTAINER_STARTUP_LOGS notice) — # applies app-wide, hoist to instance-level SummaryFetchStatus later. text = (summary_response.text or '').strip() - return startup_by_machine, (text or None) + return startup_by_machine, (text or None), startup_summary_url startup_by_machine, notice = _parse_startup_summary_body(body, target_machine) - return startup_by_machine, notice + return startup_by_machine, notice, startup_summary_url def _correlate_startups_with_runtime(runtime_items, startup_by_machine, app_wide_fetch_status): @@ -7887,7 +7892,7 @@ def troubleshoot_status(cmd, resource_group, name, slot=None, instance=None, rep cmd, subscription_id, resource_group, name, slot_segment, arm_instance_id, api_version, instance, id_to_machine) - startup_by_machine, app_wide_fetch_status = _fetch_startup_summaries( + startup_by_machine, app_wide_fetch_status, startup_summary_url = _fetch_startup_summaries( cmd, resource_group, name, slot, instance, runtime_items) orphan_startups = _correlate_startups_with_runtime( @@ -7899,8 +7904,9 @@ def troubleshoot_status(cmd, resource_group, name, slot=None, instance=None, rep 'instances': runtime_items, } if orphan_startups: - payload['orphanStartups'] = orphan_startups + payload['orphanStartups'] = orphan_startups[:_MAX_ORPHAN_STARTUPS] if report: + payload['startupSummaryUrl'] = startup_summary_url from azure.cli.command_modules.appservice import _troubleshoot_status_report _troubleshoot_status_report.render_report(payload) return None 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 ec0e1b13668..161b37e2778 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 @@ -2,11 +2,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import unittest -from unittest import mock +import io import os import sys import types +import unittest +from unittest import mock from collections.abc import Mapping from azure.core.exceptions import HttpResponseError @@ -1840,6 +1841,30 @@ def test_troubleshoot_status_all_instances(self, requests_get_mock, send_raw_req timeout=30, ) + @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('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('requests.get') + def test_troubleshoot_status_limits_orphan_startups_in_structured_output( + self, requests_get_mock, send_raw_request_mock, _scm_url_mock, _headers_mock): + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload({}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response([]))), + ] + requests_get_mock.return_value = self._make_response(200, json_data=[{ + 'InstanceId': 'previous-{}'.format(index), + 'Startup': {'Succeeded': 0, 'Failed': 1}, + } for index in range(6)]) + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp') + + self.assertEqual(len(result['orphanStartups']), 5) + self.assertEqual( + [entry['InstanceId'] for entry in result['orphanStartups']], + ['previous-{}'.format(index) for index in range(5)]) + @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', @@ -2078,6 +2103,64 @@ def test_troubleshoot_status_report_flag_renders_and_returns_none( rendered_payload = render_mock.call_args.args[0] self.assertEqual(rendered_payload['name'], 'myApp') self.assertEqual(rendered_payload['instances'][0]['instanceId'], '7c2d9') + self.assertEqual( + rendered_payload['startupSummaryUrl'], + 'https://myapp.scm.azurewebsites.net/api/startuplogs/summary') + + def test_troubleshoot_status_report_supports_legacy_windows_encodings(self): + from azure.cli.command_modules.appservice import _troubleshoot_status_report as report_module + + payload = { + 'name': 'myApp', + 'resourceGroup': 'myRG', + 'instances': [{ + 'instanceId': '7c2d9', + 'machineName': 'lw0sdlwk0007AB', + 'state': 'Failed', + 'lastError': 'ContainerTimeout', + 'startup': {'Succeeded': 1, 'Failed': 1}, + }], + 'orphanStartups': [{ + 'InstanceId': 'orphan', + 'Startup': {'Succeeded': 0, 'Failed': 1}, + }], + } + + for encoding in ('cp1252', 'cp437'): + with self.subTest(encoding=encoding): + output = io.TextIOWrapper(io.BytesIO(), encoding=encoding, errors='strict') + with mock.patch.object(report_module.sys, 'stdout', output): + report_module.render_report(payload) + output.flush() + + def test_troubleshoot_status_report_limits_orphan_startups(self): + from azure.cli.command_modules.appservice import _troubleshoot_status_report as report_module + + payload = { + 'name': 'myApp', + 'startupSummaryUrl': 'https://myapp.scm.azurewebsites.net/api/startuplogs/summary', + 'orphanStartups': [{ + 'InstanceId': 'orphan-{}'.format(index), + 'Startup': {'Succeeded': 0, 'Failed': 1}, + } for index in range(6)], + } + output = io.StringIO() + + with mock.patch.object(report_module.sys, 'stdout', output): + report_module.render_report(payload) + + rendered = output.getvalue() + for index in range(5): + self.assertIn('Instance orphan-{} Startup Summary'.format(index), rendered) + self.assertNotIn('Instance orphan-5 Startup Summary', rendered) + self.assertIn( + 'Below startup records are from instances that no longer host the application ' + 'but occurred within the last 24 hours. Limited to 5 instances.', + rendered) + self.assertIn( + 'To review the full 24-hour history, visit ' + 'https://myapp.scm.azurewebsites.net/api/startuplogs/summary.', + rendered) def test_transform_troubleshoot_status_output_renders_error_columns(self): # Regression: the LastError* columns exercise _format_dt only when @@ -2999,6 +3082,34 @@ def test_report_uses_instance_id_when_machine_name_and_filter_are_missing(self): self.assertIn('Instance:', printed_text) self.assertIn('f5105a099b', printed_text) + def test_report_supports_legacy_windows_encodings(self): + from azure.cli.command_modules.appservice import _troubleshoot_config_report as report_module + + payload = { + 'name': 'myApp', + 'resourceGroup': 'myRG', + 'configCheck': { + 'Settings': [{ + 'Setting': 'alwaysOn', + 'Value': 'false', + 'Details': 'Enable Always On.', + 'DetailsLevel': 'warning', + }], + }, + 'runtimeError': { + 'instanceId': 'f5105a099b0b07252d1991ca4444a69293f25b3fe8f8d948a2a5d46c82d33e9c5', + 'state': 'Failed', + 'lastError': 'ContainerTimeout', + }, + } + + for encoding in ('cp1252', 'cp437'): + with self.subTest(encoding=encoding): + output = io.TextIOWrapper(io.BytesIO(), encoding=encoding, errors='strict') + with mock.patch.object(report_module.sys, 'stdout', output): + report_module.render_report(payload) + output.flush() + # ---- _extract_runtime_error ---- def test_extract_runtime_error_picks_latest_timestamp(self):