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 70f51f47cb8..7b34a6573f6 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -2494,6 +2494,44 @@ text: az webapp log startup show --name MyWebApp --resource-group MyResourceGroup --instance lw0sdlwk000002 """ +helps['webapp troubleshoot config'] = """ +type: command +short-summary: Validate configuration for a Linux web app and surface a recent runtime error. +long-summary: > + Aggregates two data sources into a single report: + + (1) Built-in configuration checks — a set of common + Linux App Service settings (linuxFxVersion, port binding, startup + command, alwaysOn, health check path, ...) evaluated against the + running site's configuration snapshot. + + (2) The site runtime status error reported by App Service for the worker + represented by the configuration-check snapshot. + Use `--instance` with a worker machine name to retrieve that worker's + configuration checks. The instance ID returned by those checks is used + to select the matching runtime error. If the configuration snapshot is + unavailable, the machine name is resolved through ARM so an error from + another worker is not returned. + Runtime errors are surfaced only when they occurred within the last + 15 minutes. Older errors are omitted from both structured output and + the `--report` view. + For a 24-hour lookback of runtime status and startup attempts, run + `az webapp troubleshoot status`. + + By default the command returns a structured payload so the standard + `-o json/yaml/tsv/table` formatters handle output. Pass `--report` to + print a human-readable two-section report to stdout instead. +examples: + - name: Run the built-in configuration checks and show a recent runtime error, if any (JSON by default) + text: az webapp troubleshoot config --name MyWebApp --resource-group MyResourceGroup + - name: Print the human-readable report + text: az webapp troubleshoot config --name MyWebApp --resource-group MyResourceGroup --report + - name: Target a deployment slot + text: az webapp troubleshoot config --name MyWebApp --resource-group MyResourceGroup --slot staging + - name: Run checks and show a recent runtime error for a specific worker instance + text: az webapp troubleshoot config --name MyWebApp --resource-group MyResourceGroup --instance lw0sdlwk000002 +""" + helps['webapp troubleshoot'] = """ type: group short-summary: Diagnose common Linux web app problems. 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 8a7d6a259e8..01a2fca475e 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -856,6 +856,16 @@ def load_arguments(self, _): 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('webapp troubleshoot config') as c: + c.argument('name', arg_type=webapp_name_arg_type, id_part=None) + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('slot', options_list=['--slot', '-s'], help="the name of the slot. Defaults to the production slot if not specified") + c.argument('instance', options_list=['--instance'], + help='Filter configuration checks by worker machine name. The runtime error recommendation ' + 'uses the corresponding ARM instance ID and never falls back to another worker.') + c.argument('report', options_list=['--report'], arg_type=get_three_state_flag(), + help='Print a human-readable report instead of the structured payload.') + with self.argument_context('webapp troubleshoot status') as c: c.argument('name', arg_type=webapp_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/_troubleshoot_config_report.py b/src/azure-cli/azure/cli/command_modules/appservice/_troubleshoot_config_report.py new file mode 100644 index 00000000000..ac0e0f3a49e --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/appservice/_troubleshoot_config_report.py @@ -0,0 +1,253 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Human-readable report rendering for 'az webapp troubleshoot config --report'. + +Extracted from ``custom.py`` to keep the command's control flow separate from +its presentation layer. The command builds a structured payload; this module +renders it. ``render_report(payload)`` is the sole public entry point. +""" + +import shutil +import sys +import textwrap +from datetime import datetime, timezone + +from azure.cli.core.style import Style, print_styled_text + + +def _format_dt(value): + """Human-readable timestamp: 'YYYY-MM-DD HH:MM:SS UTC' (or best-effort).""" + if not value: + return None + if isinstance(value, str): + v = value.replace('T', ' ') + is_utc = v.endswith('Z') + if '.' in v: + v = v.split('.', 1)[0] + if is_utc: + if v.endswith('Z'): + v = v[:-1] + v = v + ' UTC' + elif v.endswith('+00:00'): + v = v[:-6] + ' UTC' + elif '+' in v: + v = v.split('+', 1)[0] + return v + return str(value) + + +def _short_id(instance_id): + """Truncate a long hex ARM instanceId to 10 characters for display.""" + if not instance_id: + return None + if len(instance_id) > 12: + return instance_id[:10] + return instance_id + + +def _relative_age(iso_value): + """Return a short 'Nh Mm ago' / 'Nm ago' / 'just now' / 'in the future' string + for an ISO-8601 UTC timestamp, or None if the input is unparseable/missing.""" + if not iso_value or not isinstance(iso_value, str): + return None + v = iso_value + if '.' in v: + head, _, tail = v.partition('.') + tz = '' + for suffix in ('Z', '+', '-'): + if suffix in tail: + idx = tail.find(suffix) + tz = tail[idx:] + break + v = head + tz + v = v.replace('Z', '+00:00') + try: + dt = datetime.fromisoformat(v) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + total_seconds = int((datetime.now(timezone.utc) - dt).total_seconds()) + if total_seconds < 0: + age = 'in the future' + elif total_seconds < 60: + age = 'just now' + else: + minutes = total_seconds // 60 + if minutes < 60: + age = '{}m ago'.format(minutes) + else: + hours, rem_min = divmod(minutes, 60) + if hours < 24: + age = '{}h {}m ago'.format(hours, rem_min) if rem_min else '{}h ago'.format(hours) + else: + days, rem_hours = divmod(hours, 24) + age = '{}d {}h ago'.format(days, rem_hours) if rem_hours else '{}d ago'.format(days) + return age + + +def _out(*objs): + print_styled_text(*objs, file=sys.stdout) + + +def _row(*objs): + _out(list(objs)) + + +def _labeled(label, value, style=Style.PRIMARY): + """Emit a labeled value with wrapped lines aligned below the value.""" + text = '' if value is None else str(value) + term_w = shutil.get_terminal_size(fallback=(120, 40)).columns + indent = ' ' * len(label) + lines = textwrap.wrap(text, width=max(20, term_w - len(label))) or [text] + _row((style, label), (style, lines[0])) + for continuation in lines[1:]: + _row((style, indent), (style, continuation)) + + +def _details_level(setting): + raw = setting.get('DetailsLevel') + if raw is None: + raw = setting.get('detailsLevel') + level = raw.strip().lower() if isinstance(raw, str) else '' + return level if level in ('info', 'warning', 'error') else 'info' + + +def _style_for_level(level): + if level == 'error': + return Style.ERROR + if level == 'warning': + return Style.WARNING + return Style.SUCCESS + + +def _get_settings(config_check): + settings = config_check.get('Settings') or config_check.get('settings') or [] + if not isinstance(settings, list): + return [] + return [setting for setting in settings if isinstance(setting, dict)] + + +def _render_snapshot_metadata(payload, config_check): + machine_name = config_check.get('MachineName') or config_check.get('machineName') + requested_machine_name = payload.get('requestedMachineName') + instance_id = config_check.get('InstanceId') or config_check.get('instanceId') + written_at_raw = config_check.get('WrittenAt') or config_check.get('writtenAt') + if isinstance(machine_name, str): + machine_name = machine_name.strip() + if isinstance(requested_machine_name, str): + requested_machine_name = requested_machine_name.strip() + if isinstance(written_at_raw, str): + written_at_raw = written_at_raw.strip() + + instance_value = machine_name or requested_machine_name or _short_id(instance_id) + if instance_value: + _labeled('Instance: ', instance_value, Style.HIGHLIGHT) + if written_at_raw: + _labeled('Last Updated: ', _format_dt(written_at_raw) or str(written_at_raw), Style.HIGHLIGHT) + + +def _render_settings_table(settings): + term_w = shutil.get_terminal_size(fallback=(120, 40)).columns + setting_w = max(20, min(40, max(len(str(s.get('Setting') or '')) for s in settings) + 2)) + value_w = max(15, min(30, max(len(str(s.get('Value') or '')) for s in settings) + 2)) + header = '{sname:<{sw}}{vname:<{vw}}{dname}'.format( + 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))) + + for setting in settings: + name = str(setting.get('Setting') or '') + value = str(setting.get('Value') if setting.get('Value') is not None else '') + details = str(setting.get('Details') or '') + prefix = '{s:<{sw}}{v:<{vw}}'.format(s=name, sw=setting_w, v=value, vw=value_w) + lines = textwrap.wrap(details, width=max(20, term_w - len(prefix))) or [details] + details_style = _style_for_level(_details_level(setting)) + _row((Style.PRIMARY, prefix), (details_style, lines[0])) + for continuation in lines[1:]: + _row((Style.PRIMARY, ' ' * len(prefix)), (details_style, continuation)) + + +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)) + _out() + if payload.get('configCheck') is None: + if payload.get('configCheckStatus') == 404: + message = payload.get('configCheckMessage') or ( + 'Configuration check feature is currently disabled. Please try again later.') + _row((Style.WARNING, message)) + else: + _row((Style.WARNING, + 'Failed to retrieve built-in configuration checks. Please try again. ' + 'If the issue persists, restart the application (\'az webapp restart\') and confirm the SCM (Kudu) ' + 'is running and reachable.')) + return + + if not settings: + _row((Style.WARNING, 'No built-in configuration checks reported.')) + else: + _render_settings_table(settings) + + +def _render_runtime_error(runtime_error): + _out() + _out() + _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 '') + age = _relative_age(timestamp_raw) if timestamp else None + if age: + timestamp = '{} ({})'.format(timestamp, age) + + fields = [ + ('Instance ', _short_id(runtime_error.get('instanceId'))), + ('State ', runtime_error.get('state')), + ('Last Error ', runtime_error.get('lastError')), + ('Last Error Details ', runtime_error.get('lastErrorDetails')), + ('Last Error Timestamp ', timestamp), + ] + for label, value in fields: + if value: + _labeled(label, value) + _out() + + +def _render_hints(payload, any_issue): + resource_group = payload.get('resourceGroup') or '' + site_name = payload.get('name') or '' + slot = payload.get('slot') + slot_arg = ' --slot {}'.format(slot) if slot else '' + _out() + _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)) + _out(' Review config options: az webapp config set -n {} -g {}{} ' + '--help'.format(site_name, resource_group, slot_arg)) + _out(' Check application logs: az webapp log tail -n {} -g {}{}'.format( + site_name, resource_group, slot_arg)) + + +def render_report(payload): + """Print built-in checks, a recent runtime recommendation, and hints.""" + config_check = payload.get('configCheck') or {} + settings = _get_settings(config_check) + runtime_error = payload.get('runtimeError') + show_runtime = bool(runtime_error) + any_issue = any(_details_level(setting) in ('warning', 'error') for setting in settings) + + _render_config_checks(payload, config_check, settings) + if show_runtime: + _render_runtime_error(runtime_error) + if any_issue or show_runtime: + _render_hints(payload, any_issue) 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 c621b76fa04..b3960e06b09 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -48,6 +48,34 @@ def transform_runtime_list_output(result): ]) for r in result] +def transform_troubleshoot_config_output(result): + """Flatten the troubleshoot config payload into a per-setting table. + + Reads Settings out of the nested ``configCheck`` field (the verbatim SCM + body). Falls back gracefully for non-dict / empty payloads (e.g. --report + was passed and the command returned ``None``). + """ + from collections import OrderedDict + if not isinstance(result, dict): + return [] + config_check = result.get('configCheck') or {} + settings = config_check.get('Settings') or config_check.get('settings') or [] + if not isinstance(settings, list): + return [] + written_at = config_check.get('WrittenAt') or config_check.get('writtenAt') + if isinstance(written_at, str): + written_at = written_at.strip() + details_header = ( + 'Details (Last Updated: {})'.format(written_at) + if written_at else 'Details' + ) + return [OrderedDict([ + ('Setting', s.get('Setting') or s.get('setting') or ''), + ('Value', s.get('Value') if s.get('Value') is not None else s.get('value') or ''), + (details_header, s.get('Details') or s.get('details') or ''), + ]) for s in settings if isinstance(s, dict)] + + def transform_troubleshoot_status_output(result): """Flatten the nested `instances` payload into one row per worker for `-o table`. Column layout: InstanceId / State / Details / (LastError / @@ -127,7 +155,6 @@ def _print_hint(app=app_name, rg=resource_group): .format(name=app, rg=rg)) atexit.register(_print_hint) - return rows @@ -349,6 +376,8 @@ def load_command_table(self, _): g.custom_show_command('show', 'show_startup_log') with self.command_group('webapp troubleshoot', is_preview=True) as g: + g.custom_command('config', 'troubleshoot_config', + table_transformer=transform_troubleshoot_config_output) g.custom_command('status', 'troubleshoot_status', table_transformer=transform_troubleshoot_status_output) 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 61786d5c7ec..a7d0316d1db 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -422,10 +422,19 @@ def create_webapp(cmd, resource_group_name, name, plan, runtime=None, startup_fi multicontainer_config_type, sitecontainers_app, deployment_source_url, deployment_local_git]): logger.warning("Webapp '%s' created. Deploy your code with: az webapp deploy", name) + _log_webapp_troubleshoot_config_tip(name, resource_group_name, is_linux) _log_webapp_troubleshoot_status_tip(name, resource_group_name, is_linux) return webapp +def _log_webapp_troubleshoot_config_tip(name, resource_group_name, is_linux): + if not is_linux: + return + logger.warning("Tip: run 'az webapp troubleshoot config --name %s --resource-group %s --report' " + "to validate app configuration and see recent runtime errors.", + name, resource_group_name) + + def _log_webapp_troubleshoot_status_tip(name, resource_group_name, is_linux): # Per-instance runtime status (siteStatus) is a Linux App Service feature, # so only surface the tip for Linux webapps. @@ -6642,6 +6651,321 @@ def show_startup_log(cmd, resource_group, name, slot=None, filename=None, instan return response.json() +# ----------------------------------------------------------------------------- +# az webapp troubleshoot config +# ----------------------------------------------------------------------------- + +# Runtime-error freshness window. Both the structured payload and the --report +# view surface the runtime error only when its lastErrorTimestamp is within +# this many minutes of "now", so scripts and human readers agree. +_RUNTIME_ERROR_FRESHNESS_MINUTES = 15 + + +def _runtime_error_is_recent(runtime_error, minutes=_RUNTIME_ERROR_FRESHNESS_MINUTES): + """Return True iff the runtime error's lastErrorTimestamp is within the + last N minutes (UTC). ARM emits lastErrorTimestamp as an ISO 8601 string; + tolerate a trailing 'Z' and missing tzinfo (treated as UTC).""" + if not runtime_error: + return False + raw = runtime_error.get('lastErrorTimestamp') + if not raw: + return False + try: + ts = str(raw).strip() + if ts.endswith('Z'): + ts = ts[:-1] + '+00:00' + parsed = datetime.datetime.fromisoformat(ts) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.timezone.utc) + except (ValueError, TypeError): + return False + delta = datetime.datetime.now(datetime.timezone.utc) - parsed + # Reject future timestamps: a clock-skewed or malformed value would + # produce a negative delta, which still satisfies `<= 15min` and would + # incorrectly mark stale errors as recent. + return datetime.timedelta(0) <= delta <= datetime.timedelta(minutes=minutes) + + +def _ensure_linux_webapp_for_troubleshoot(cmd, resource_group_name, name, slot=None): + client = web_client_factory(cmd.cli_ctx) + if slot: + app = client.web_apps.get_slot(resource_group_name, name, slot) + else: + app = client.web_apps.get(resource_group_name, name) + if app is None or not is_linux_webapp(app): + raise ArgumentUsageError( + "'az webapp troubleshoot config' is only supported for Linux web apps.") + + +def _extract_runtime_error(arm_response, instance_id=None): + """Return the runtime-error block from an ARM /siteStatus response. + + /siteStatus returns per-instance status under 'properties' (a list); the + single-instance form returns a dict. When ``instance_id`` is provided, only + that worker is considered; otherwise, pick the entry with the latest + ``lastErrorTimestamp`` that also has a non-empty ``lastError``. Returns + ``None`` when no matching runtime error is reported. + """ + if not isinstance(arm_response, dict): + return None + properties = arm_response.get('properties') + if isinstance(properties, list): + items = properties + elif isinstance(properties, dict): + items = [properties] + else: + return None + candidates = [item for item in items if isinstance(item, dict) and item.get('lastError')] + if instance_id: + requested_instance = str(instance_id).casefold() + candidates = [ + item for item in candidates + if str(item.get('instanceId') or '').casefold() == requested_instance + ] + if not candidates: + return None + + def _ts_key(item): + return item.get('lastErrorTimestamp') or '' + + candidates.sort(key=_ts_key, reverse=True) + return candidates[0] + + +def _http_error_status(ex): + """Return a customer-safe HTTP status without including response content.""" + response = getattr(ex, 'response', None) + status_code = getattr(response, 'status_code', None) or getattr(ex, 'status_code', None) + return 'status {}'.format(status_code) if status_code is not None else ex.__class__.__name__ + + +def _safe_response_message(response_text): + """Return a short plain-text response message, excluding HTML error pages.""" + if not isinstance(response_text, str): + return None + message = ' '.join(response_text.split()) + if not message or message.startswith('<') or ' 1: + text += str(item[1]) + elif isinstance(arg, tuple) and len(arg) > 1: + text += str(arg[1]) + elif isinstance(arg, str): + text += arg + return text + + def test_table_output_includes_config_written_time_in_details_header(self): + result = transform_troubleshoot_config_output({ + 'configCheck': { + 'WrittenAt': '2026-09-02T17:30:00Z', + 'Settings': [{ + 'Setting': 'alwaysOn', + 'Value': 'true', + 'Details': 'No issues detected.', + }], + }, + }) + + self.assertEqual( + list(result[0].keys()), + ['Setting', 'Value', 'Details (Last Updated: 2026-09-02T17:30:00Z)']) + + def test_table_output_uses_details_header_when_written_time_is_missing(self): + result = transform_troubleshoot_config_output({ + 'configCheck': { + 'WrittenAt': ' ', + 'Settings': [{ + 'Setting': 'alwaysOn', + 'Value': 'true', + 'Details': 'No issues detected.', + }], + }, + }) + + self.assertEqual(list(result[0].keys()), ['Setting', 'Value', 'Details']) + + def test_report_omits_empty_snapshot_metadata(self): + from azure.cli.command_modules.appservice._troubleshoot_config_report import render_report + + payload = { + 'configCheck': { + 'MachineName': ' ', + 'WrittenAt': '', + 'Settings': [], + }, + } + with mock.patch( + 'azure.cli.command_modules.appservice._troubleshoot_config_report.print_styled_text') as print_mock: + render_report(payload) + + printed_text = self._printed_text(print_mock) + self.assertNotIn('Instance:', printed_text) + self.assertNotIn('Last Updated:', printed_text) + + def test_report_uses_requested_machine_when_response_omits_machine_name(self): + from azure.cli.command_modules.appservice._troubleshoot_config_report import render_report + + payload = { + 'configCheck': { + 'WrittenAt': '2026-09-02T17:30:00Z', + 'Settings': [], + }, + 'requestedMachineName': 'pl0sdlwk000r7s', + } + with mock.patch( + 'azure.cli.command_modules.appservice._troubleshoot_config_report.print_styled_text') as print_mock: + render_report(payload) + + printed_text = self._printed_text(print_mock) + self.assertIn('Instance:', printed_text) + self.assertIn('pl0sdlwk000r7s', printed_text) + + def test_report_uses_instance_id_when_machine_name_and_filter_are_missing(self): + from azure.cli.command_modules.appservice._troubleshoot_config_report import render_report + + payload = { + 'configCheck': { + 'InstanceId': 'f5105a099b0b07252d1991ca4444a69293f25b3fe8f8d948a2a5d46c82d33e9c5', + 'WrittenAt': '2026-09-02T17:30:00Z', + 'Settings': [], + }, + } + with mock.patch( + 'azure.cli.command_modules.appservice._troubleshoot_config_report.print_styled_text') as print_mock: + render_report(payload) + + printed_text = self._printed_text(print_mock) + self.assertIn('Instance:', printed_text) + self.assertIn('f5105a099b', printed_text) + + # ---- _extract_runtime_error ---- + + def test_extract_runtime_error_picks_latest_timestamp(self): + arm = {'properties': [ + {'state': 'Started', 'lastError': None}, + {'state': 'Stopped', 'lastError': 'A', 'lastErrorTimestamp': '2026-07-01T00:00:00Z'}, + {'state': 'Stopped', 'lastError': 'B', 'lastErrorTimestamp': '2026-07-02T00:00:00Z'}, + ]} + self.assertEqual(_extract_runtime_error(arm)['lastError'], 'B') + + def test_extract_runtime_error_uses_requested_instance(self): + arm = {'properties': [ + {'instanceId': 'config-instance', 'state': 'Stopped', + 'lastError': 'ConfigWorkerError', 'lastErrorTimestamp': '2026-07-01T00:00:00Z'}, + {'instanceId': 'other-instance', 'state': 'Stopped', + 'lastError': 'NewerOtherWorkerError', 'lastErrorTimestamp': '2026-07-02T00:00:00Z'}, + ]} + + result = _extract_runtime_error(arm, instance_id='CONFIG-INSTANCE') + + self.assertEqual(result['instanceId'], 'config-instance') + self.assertEqual(result['lastError'], 'ConfigWorkerError') + + def test_extract_runtime_error_returns_none_when_requested_instance_has_no_error(self): + arm = {'properties': [ + {'instanceId': 'config-instance', 'state': 'Started', 'lastError': None}, + {'instanceId': 'other-instance', 'state': 'Stopped', + 'lastError': 'OtherWorkerError', 'lastErrorTimestamp': '2026-07-02T00:00:00Z'}, + ]} + + self.assertIsNone(_extract_runtime_error(arm, instance_id='config-instance')) + + def test_extract_runtime_error_returns_none_when_all_started(self): + arm = {'properties': [{'state': 'Started', 'lastError': None}]} + self.assertIsNone(_extract_runtime_error(arm)) + + def test_extract_runtime_error_handles_single_dict_properties(self): + arm = {'properties': {'state': 'Stopped', 'lastError': 'X'}} + self.assertEqual(_extract_runtime_error(arm)['lastError'], 'X') + + def test_extract_runtime_error_handles_missing_properties(self): + self.assertIsNone(_extract_runtime_error({})) + self.assertIsNone(_extract_runtime_error(None)) + + # ---- troubleshoot_config ---- + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_success(self, requests_get_mock, _scm_url_mock, + _headers_mock, send_raw_request_mock): + from datetime import datetime, timezone + fresh_ts = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + settings = [ + {'Setting': 'linuxFxVersion', 'Value': 'NODE|20-lts', 'Details': 'No issues detected'}, + {'Setting': 'alwaysOn', 'Value': 'false', 'Details': 'App may be unloaded when idle'}, + ] + scm_body = { + 'SiteName': 'myApp', + 'InstanceId': 'abc123', + 'WrittenAt': '2026-07-07T18:12:29+00:00', + 'Settings': settings, + } + requests_get_mock.return_value = self._scm_response(200, json_data=scm_body) + send_raw_request_mock.return_value = self._arm_response({'properties': [ + {'instanceId': 'abc123', 'state': 'Stopped', 'lastError': 'ContainerTimeout', + 'lastErrorDetails': 'Container did not respond', 'lastErrorAction': 'WaitingForSiteToStart', + 'lastErrorTimestamp': fresh_ts}, + {'instanceId': 'other-instance', 'state': 'Stopped', 'lastError': 'OtherWorkerError', + 'lastErrorTimestamp': fresh_ts}, + ]}) + + result = troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp') + + self.assertEqual(result['name'], 'myApp') + self.assertEqual(result['resourceGroup'], 'myRG') + # configCheck is the verbatim SCM body — PascalCase keys preserved. + self.assertEqual(result['configCheck'], scm_body) + self.assertEqual(result['configCheck']['Settings'], settings) + self.assertEqual(result['runtimeError']['lastError'], 'ContainerTimeout') + self.assertEqual(result['runtimeError']['instanceId'], 'abc123') + self.assertNotIn('configCheckStatus', result) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_filters_by_requested_instance( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + from datetime import datetime, timezone + fresh_ts = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + requests_get_mock.return_value = self._scm_response(200, json_data={ + 'SiteName': 'myApp', + 'InstanceId': 'website-instance-id', + 'Settings': [], + }) + send_raw_request_mock.return_value = self._arm_response({'properties': [ + {'instanceId': 'website-instance-id', 'state': 'Stopped', + 'lastError': 'RequestedWorkerError', 'lastErrorTimestamp': fresh_ts}, + {'instanceId': 'other-instance', 'state': 'Stopped', + 'lastError': 'NewerOtherWorkerError', 'lastErrorTimestamp': fresh_ts}, + ]}) + + result = troubleshoot_config( + _get_test_cmd(), 'myRG', 'myApp', instance='requested-instance') + + self.assertEqual( + requests_get_mock.call_args.kwargs['params'], + {'instance': 'requested-instance'}) + self.assertIsNone(requests_get_mock.call_args.kwargs['cookies']) + self.assertEqual(result['runtimeError']['instanceId'], 'website-instance-id') + self.assertEqual(result['runtimeError']['lastError'], 'RequestedWorkerError') + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_instance_filter_maps_worker_when_snapshot_is_missing( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + from datetime import datetime, timezone + fresh_ts = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + requests_get_mock.return_value = self._scm_response(404) + send_raw_request_mock.side_effect = [ + self._arm_response({'value': [ + {'name': 'website-instance-id', + 'properties': {'machineName': 'requested-instance'}}, + {'name': 'other-instance-id', + 'properties': {'machineName': 'other-instance'}}, + ]}), + self._arm_response({'properties': [ + {'instanceId': 'website-instance-id', 'state': 'Stopped', + 'lastError': 'RequestedWorkerError', 'lastErrorTimestamp': fresh_ts}, + {'instanceId': 'other-instance-id', 'state': 'Stopped', + 'lastError': 'OtherWorkerError', 'lastErrorTimestamp': fresh_ts}, + ]}), + ] + + result = troubleshoot_config( + _get_test_cmd(), 'myRG', 'myApp', instance='REQUESTED-INSTANCE') + + config_calls = [ + call for call in requests_get_mock.call_args_list + if call.args and call.args[0] == 'https://myapp.scm.azurewebsites.net/api/troubleshoot/config' + ] + self.assertEqual(len(config_calls), 1) + self.assertEqual(send_raw_request_mock.call_count, 2) + self.assertIn('/instances?', send_raw_request_mock.call_args_list[0].args[2]) + self.assertIn('/siteStatus?', send_raw_request_mock.call_args_list[1].args[2]) + self.assertEqual(result['runtimeError']['instanceId'], 'website-instance-id') + self.assertEqual(result['runtimeError']['lastError'], 'RequestedWorkerError') + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_instance_filter_does_not_fall_back_to_another_worker( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + requests_get_mock.return_value = self._scm_response(404) + send_raw_request_mock.return_value = self._arm_response({'value': [ + {'name': 'other-instance-id', + 'properties': {'machineName': 'other-instance'}}, + ]}) + + result = troubleshoot_config( + _get_test_cmd(), 'myRG', 'myApp', instance='requested-instance') + + self.assertNotIn('runtimeError', result) + self.assertEqual(send_raw_request_mock.call_count, 1) + self.assertIn('/instances?', send_raw_request_mock.call_args.args[2]) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_scm_404_returns_empty_settings(self, requests_get_mock, + _scm_url_mock, _headers_mock, + send_raw_request_mock): + requests_get_mock.return_value = self._scm_response(404) + send_raw_request_mock.return_value = self._arm_response({'properties': []}) + + with mock.patch('azure.cli.command_modules.appservice.custom.logger') as logger_mock: + result = troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp') + + self.assertIsNone(result['configCheck']) + self.assertNotIn('runtimeError', result) + # Exactly one warning: the 404 -> feature-disabled message. + logger_mock.warning.assert_any_call( + 'Configuration check feature is currently disabled. Please try again later.') + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_report_shows_feature_unavailable_on_404( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + # SCM returns 404 -> BUILT-IN CHECKS section prints the compact + # feature-disabled message instead of the generic + # retry-and-restart guidance. + requests_get_mock.return_value = self._scm_response(404) + send_raw_request_mock.return_value = self._arm_response({'properties': []}) + + with mock.patch( + 'azure.cli.command_modules.appservice._troubleshoot_config_report.print_styled_text') as print_mock: + troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp', report=True) + + printed_text = self._printed_text(print_mock) + self.assertIn( + 'Configuration check feature is currently disabled. Please try again later.', + printed_text) + self.assertNotIn('Failed to retrieve built-in configuration checks', printed_text) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_report_shows_404_response_message( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + response = self._scm_response(404) + response.text = ( + 'Config check information for PL0SDLWK000R7S could not be retrieved. ' + 'Please try a different instance.') + requests_get_mock.return_value = response + send_raw_request_mock.return_value = self._arm_response({'properties': []}) + + with mock.patch( + 'azure.cli.command_modules.appservice._troubleshoot_config_report.print_styled_text') as print_mock: + troubleshoot_config( + _get_test_cmd(), 'myRG', 'myApp', instance='PL0SDLWK000R7S', report=True) + + printed_text = self._printed_text(print_mock) + self.assertIn(response.text, printed_text) + self.assertNotIn('Configuration check feature is currently disabled', printed_text) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('time.sleep') + @mock.patch('requests.get') + def test_troubleshoot_config_scm_transient_5xx_retries_then_succeeds( + self, requests_get_mock, _sleep_mock, _scm_url_mock, _headers_mock, + send_raw_request_mock): + # First two calls fail with a transient 503, third returns the payload. + requests_get_mock.side_effect = [ + self._scm_response(503), + self._scm_response(503), + self._scm_response(200, json_data={ + 'SiteName': 'myApp', 'InstanceId': 'abc', 'WrittenAt': 't', + 'Settings': [{'Setting': 'alwaysOn', 'Value': 'true', + 'Details': 'No issues detected'}], + }), + ] + send_raw_request_mock.return_value = self._arm_response({'properties': []}) + + result = troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp') + + self.assertIsNotNone(result['configCheck']) + self.assertEqual(len(result['configCheck']['Settings']), 1) + self.assertEqual(requests_get_mock.call_count, 3) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('time.sleep') + @mock.patch('requests.get') + def test_troubleshoot_config_scm_persistent_5xx_gives_up_after_max_attempts( + self, requests_get_mock, _sleep_mock, _scm_url_mock, _headers_mock, + send_raw_request_mock): + # All attempts return 503 → configCheck stays None after 3 tries. + requests_get_mock.return_value = self._scm_response(503) + send_raw_request_mock.return_value = self._arm_response({'properties': []}) + + with mock.patch('azure.cli.command_modules.appservice.custom.logger') as logger_mock: + result = troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp') + + self.assertIsNone(result['configCheck']) + self.assertEqual(requests_get_mock.call_count, 3) + logger_mock.warning.assert_called() + + def test_runtime_error_is_recent_rejects_future_timestamp(self): + # Regression: a clock-skewed or malformed timestamp that lands in the + # future would produce a negative (now - parsed) delta which still + # satisfies `<= 15min`, so stale/nonsense errors were being flagged + # as recent. The gate must require a non-negative delta. + from datetime import datetime, timezone, timedelta + from azure.cli.command_modules.appservice.custom import _runtime_error_is_recent + future_ts = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat().replace('+00:00', 'Z') + past_recent_ts = (datetime.now(timezone.utc) - timedelta(minutes=2)).isoformat().replace('+00:00', 'Z') + past_stale_ts = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat().replace('+00:00', 'Z') + self.assertFalse(_runtime_error_is_recent({'lastErrorTimestamp': future_ts})) + self.assertTrue(_runtime_error_is_recent({'lastErrorTimestamp': past_recent_ts})) + self.assertFalse(_runtime_error_is_recent({'lastErrorTimestamp': past_stale_ts})) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_structured_output_suppresses_stale_runtime_error( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + requests_get_mock.return_value = self._scm_response(200, json_data={ + 'SiteName': 'myApp', + 'InstanceId': 'abc', + 'Settings': [], + }) + send_raw_request_mock.return_value = self._arm_response({'properties': [ + {'instanceId': 'abc', 'state': 'Stopped', 'lastError': 'ContainerTimeout', + 'lastErrorTimestamp': '2026-07-01T00:00:00Z'}, + ]}) + + result = troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp') + + self.assertNotIn('runtimeError', result) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_no_runtime_error(self, requests_get_mock, _scm_url_mock, + _headers_mock, send_raw_request_mock): + requests_get_mock.return_value = self._scm_response(200, json_data={ + 'SiteName': 'myApp', 'InstanceId': 'abc', 'WrittenAt': 't', + 'Settings': [{'Setting': 'alwaysOn', 'Value': 'true', + 'Details': 'No issues detected'}], + }) + send_raw_request_mock.return_value = self._arm_response({'properties': [ + {'instanceId': 'other-instance', 'state': 'Stopped', + 'lastError': 'OtherWorkerError', 'lastErrorTimestamp': '2026-07-02T00:00:00Z'}, + ]}) + + result = troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp') + + self.assertNotIn('runtimeError', result) + self.assertEqual(len(result['configCheck']['Settings']), 1) + self.assertEqual(result['configCheck']['SiteName'], 'myApp') + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_report_prints_and_returns_none(self, requests_get_mock, + _scm_url_mock, _headers_mock, + send_raw_request_mock): + # The runtime-error section now renders only when the ARM + # lastErrorTimestamp is within the last 15 minutes (or the built-in + # check fetch failed). Use a fresh timestamp so the section renders. + from datetime import datetime, timezone + fresh_ts = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + requests_get_mock.return_value = self._scm_response(200, json_data={ + 'SiteName': 'myApp', + 'MachineName': 'pl0sdlwk000r7s', + 'InstanceId': 'abc', + 'WrittenAt': '2026-09-02T17:30:00+00:00', + 'Settings': [ + {'Setting': 'linuxFxVersion', 'Value': 'NODE|20-lts', + 'Details': 'No issues detected', 'DetailsLevel': 'info'}, + {'Setting': 'websitesPort', 'Value': '', + 'Details': "App doesn't respond on expected port", + 'DetailsLevel': 'warning'}, + ], + }) + send_raw_request_mock.return_value = self._arm_response({'properties': [ + {'instanceId': 'abc', 'state': 'Stopped', 'lastError': 'ImagePullUnauthorizedFailure', + 'lastErrorDetails': 'forbidden', 'lastErrorAction': 'StartingSiteContainers', + 'lastErrorTimestamp': fresh_ts}, + ]}) + + with mock.patch( + 'azure.cli.command_modules.appservice._troubleshoot_config_report.print_styled_text') as print_mock: + result = troubleshoot_config( + _get_test_cmd(), 'myRG', 'myApp', slot='staging', report=True) + + self.assertIsNone(result) + printed_text = self._printed_text(print_mock) + self.assertIn('BUILT-IN CHECKS', printed_text) + self.assertIn('Instance:', printed_text) + self.assertIn('pl0sdlwk000r7s', printed_text) + self.assertIn('Last Updated:', printed_text) + self.assertIn('2026-09-02 17:30:00 UTC', printed_text) + self.assertLess(printed_text.index('Instance:'), printed_text.index('BUILT-IN CHECKS')) + self.assertLess(printed_text.index('Last Updated:'), printed_text.index('BUILT-IN CHECKS')) + self.assertIn('SITE RUNTIME ERROR RECOMMENDATION', printed_text) + self.assertIn('ImagePullUnauthorizedFailure', printed_text) + self.assertIn('Last Error', printed_text) + self.assertIn('Last Error Details', printed_text) + self.assertIn('Last Error Timestamp', printed_text) + self.assertIn( + 'az webapp config appsettings set -n myApp -g myRG ' + '--slot staging --settings KEY=VALUE', + printed_text) + self.assertIn( + 'az webapp config set -n myApp -g myRG --slot staging --help', + printed_text) + self.assertIn( + 'az webapp log tail -n myApp -g myRG --slot staging', + printed_text) + # Removed labels should NOT appear. + self.assertNotIn('Last runtime error', printed_text) + self.assertNotIn('Action:', printed_text) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_report_suppresses_stale_runtime_error( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + # A stale ARM timestamp suppresses the runtime recommendation. + requests_get_mock.return_value = self._scm_response(200, json_data={ + 'SiteName': 'myApp', 'InstanceId': 'abc', 'WrittenAt': 't', + 'Settings': [ + {'Setting': 'linuxFxVersion', 'Value': 'NODE|20-lts', + 'Details': 'No issues detected', 'DetailsLevel': 'info'}, + {'Setting': 'alwaysOn', 'Value': 'true', + 'Details': 'No issues detected', 'DetailsLevel': 'info'}, + ], + }) + send_raw_request_mock.return_value = self._arm_response({'properties': [ + {'instanceId': 'abc', 'state': 'Started', 'lastError': 'ContainerTimeout', + 'lastErrorDetails': 'old error', 'lastErrorAction': 'None', + 'lastErrorTimestamp': '2026-07-01T00:00:00Z'}, + ]}) + + with mock.patch( + 'azure.cli.command_modules.appservice._troubleshoot_config_report.print_styled_text') as print_mock: + troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp', report=True) + + printed_text = self._printed_text(print_mock) + self.assertIn('BUILT-IN CHECKS', printed_text) + self.assertNotIn('SITE RUNTIME ERROR RECOMMENDATION', printed_text) + self.assertNotIn('ContainerTimeout', printed_text) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_report_shows_runtime_when_error_is_fresh( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + # All settings clean, but ARM reports a runtime error whose timestamp + # is within the last 15 minutes -> section should still render. + from datetime import datetime, timezone, timedelta + fresh_ts = (datetime.now(timezone.utc) - timedelta(minutes=2)).strftime( + '%Y-%m-%dT%H:%M:%SZ') + requests_get_mock.return_value = self._scm_response(200, json_data={ + 'SiteName': 'myApp', 'InstanceId': 'abc', 'WrittenAt': 't', + 'Settings': [ + {'Setting': 'linuxFxVersion', 'Value': 'NODE|20-lts', + 'Details': 'No issues detected', 'DetailsLevel': 'info'}, + ], + }) + send_raw_request_mock.return_value = self._arm_response({'properties': [ + {'instanceId': 'abc', 'state': 'Stopped', 'lastError': 'ContainerTimeout', + 'lastErrorDetails': 'fresh error', 'lastErrorAction': 'None', + 'lastErrorTimestamp': fresh_ts}, + ]}) + + with mock.patch( + 'azure.cli.command_modules.appservice._troubleshoot_config_report.print_styled_text') as print_mock: + troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp', report=True) + + printed_text = self._printed_text(print_mock) + self.assertIn('SITE RUNTIME ERROR RECOMMENDATION', printed_text) + self.assertIn('ContainerTimeout', printed_text) + self.assertIn('fresh error', printed_text) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_report_does_not_use_generic_details_for_last_error( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + from datetime import datetime, timezone + fresh_ts = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + requests_get_mock.return_value = self._scm_response(503) + send_raw_request_mock.return_value = self._arm_response({'properties': [ + {'state': 'Started', 'lastError': 'IssueStartingContainer', + 'lastErrorDetails': '', 'details': 'Site started successfully.', + 'lastErrorTimestamp': fresh_ts}, + ]}) + + with mock.patch( + 'azure.cli.command_modules.appservice._troubleshoot_config_report.print_styled_text') as print_mock: + troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp', report=True) + + printed_text = self._printed_text(print_mock) + self.assertIn('IssueStartingContainer', printed_text) + self.assertNotIn('Last Error Details', printed_text) + self.assertNotIn('Site started successfully.', printed_text) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_report_shows_runtime_when_config_check_failed( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + # All SCM retries fail (persistent 503) -> configCheck is None. The + # runtime section should render only when the ARM lastErrorTimestamp + # is fresh: the timestamp gate is applied consistently regardless of + # whether the built-in checks are available. + from datetime import datetime, timezone, timedelta + fresh_ts = (datetime.now(timezone.utc) - timedelta(minutes=2)).strftime( + '%Y-%m-%dT%H:%M:%SZ') + requests_get_mock.return_value = self._scm_response(503) + send_raw_request_mock.return_value = self._arm_response({'properties': [ + {'state': 'Stopped', 'lastError': 'ContainerTimeout', + 'lastErrorDetails': 'fresh error', 'lastErrorAction': 'None', + 'lastErrorTimestamp': fresh_ts}, + ]}) + + with mock.patch( + 'azure.cli.command_modules.appservice._troubleshoot_config_report.print_styled_text') as print_mock: + troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp', report=True) + + printed_text = self._printed_text(print_mock) + self.assertIn('Failed to retrieve built-in configuration checks', printed_text) + self.assertIn('SITE RUNTIME ERROR RECOMMENDATION', printed_text) + self.assertIn('ContainerTimeout', printed_text) + self.assertIn('fresh error', printed_text) + self.assertIn('Hint:', printed_text) + self.assertIn('az webapp log tail', printed_text) + self.assertNotIn('Update flagged app setting', printed_text) + self.assertNotIn('Review config options', printed_text) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_report_suppresses_runtime_when_config_check_failed_and_error_is_stale( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + # All SCM retries fail AND the ARM timestamp is stale -> runtime + # section should NOT render. Config-check failure alone is not enough + # to surface a stale runtime error. + requests_get_mock.return_value = self._scm_response(503) + send_raw_request_mock.return_value = self._arm_response({'properties': [ + {'state': 'Stopped', 'lastError': 'ContainerTimeout', + 'lastErrorDetails': 'stale error', 'lastErrorAction': 'None', + 'lastErrorTimestamp': '2026-07-01T00:00:00Z'}, + ]}) + + with mock.patch( + 'azure.cli.command_modules.appservice._troubleshoot_config_report.print_styled_text') as print_mock: + troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp', report=True) + + printed_text = self._printed_text(print_mock) + self.assertIn('Failed to retrieve built-in configuration checks', printed_text) + self.assertNotIn('SITE RUNTIME ERROR RECOMMENDATION', printed_text) + + def test_troubleshoot_config_raises_on_windows(self): + with mock.patch( + 'azure.cli.command_modules.appservice.custom.is_linux_webapp', + return_value=False): + with self.assertRaises(ArgumentUsageError) as cm: + troubleshoot_config(_get_test_cmd(), 'myRG', 'myWindowsApp') + self.assertIn('Linux', str(cm.exception)) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_retries_across_instances_on_404( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + # First (unpinned) call and the first instance retry return 404 with + # KuduLite's "not available" body; the second instance retry returns + # the snapshot. + settings = [{'Setting': 'linuxFxVersion', 'Value': 'NODE|20-lts', + 'Details': 'No issues detected'}] + good = self._scm_response(200, json_data={ + 'SiteName': 'myApp', 'InstanceId': 'inst2', 'WrittenAt': 't', + 'Settings': settings, + }) + bad = mock.MagicMock() + bad.status_code = 404 + bad.text = 'Config check information is not available.' + bad.json.side_effect = ValueError('not json') + + requests_get_mock.side_effect = [bad, bad, good] + + send_raw_request_mock.side_effect = [ + # ARM /instances response (retry lookup). + self._arm_response({'value': [{'name': 'inst1'}, {'name': 'inst2'}]}), + # ARM /siteStatus response. + self._arm_response({'properties': []}), + ] + + result = troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp') + + self.assertIsNotNone(result['configCheck']) + self.assertEqual(result['configCheck']['InstanceId'], 'inst2') + # 3 SCM calls: unpinned, then per-instance retry twice. + self.assertEqual(requests_get_mock.call_count, 3) + # Second and third calls should carry ARR affinity cookies. + self.assertEqual( + requests_get_mock.call_args_list[1].kwargs['cookies'], + {'ARRAffinity': 'inst1', 'ARRAffinitySameSite': 'inst1'}) + self.assertEqual( + requests_get_mock.call_args_list[2].kwargs['cookies'], + {'ARRAffinity': 'inst2', 'ARRAffinitySameSite': 'inst2'}) + + @mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers', + return_value={'Authorization': '******'}) + @mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url', + return_value='https://myapp.scm.azurewebsites.net') + @mock.patch('requests.get') + def test_troubleshoot_config_suppresses_arm_error_response_body( + self, requests_get_mock, _scm_url_mock, _headers_mock, send_raw_request_mock): + unavailable = mock.MagicMock() + unavailable.status_code = 503 + unavailable.reason = 'Service Unavailable' + arm_error = HttpResponseError( + message='Azure services are not available right now.', + response=unavailable) + requests_get_mock.return_value = self._scm_response(404) + send_raw_request_mock.side_effect = [ + arm_error, + self._arm_response({'properties': []}), + ] + + with mock.patch('azure.cli.command_modules.appservice.custom.logger') as logger_mock: + troubleshoot_config(_get_test_cmd(), 'myRG', 'myApp') + + warning = logger_mock.warning.call_args_list[0] + rendered_warning = warning.args[0] % warning.args[1:] + self.assertIn('status 503', rendered_warning) + self.assertNotIn('', rendered_warning) + self.assertNotIn('Azure services are not available', rendered_warning) + + class _TypespecContainerSettings(Mapping): """Mimics an azure-mgmt-web typespec/DPG container settings model.