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 eea863ae35d..c2ea699152c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -2432,6 +2432,54 @@ text: az webapp log startup show --name MyWebApp --resource-group MyResourceGroup --instance lw0sdlwk000002 """ +helps['webapp troubleshoot'] = """ +type: group +short-summary: Diagnose common Linux web app problems. +long-summary: > + Preview command group that pairs built-in configuration checks (from + KuduLite on the worker) with per-instance runtime status and startup + summaries from ARM. Use when a Linux app is failing to start, returning + HTTP 502/503, or exhibiting other post-deployment misbehavior. +""" + +helps['webapp troubleshoot status'] = """ +type: command +short-summary: Show site runtime status and recent startup summary for a Linux web app. +long-summary: | + Aggregates two data sources: + + - Site Runtime Status + - Startup summary: KuduLite (SCM) /api/startuplogs/summary (counts of + successful and failed startup attempts in the last 24h, plus the + most recent success and failure timestamps). + + Use --instance to scope both to a single worker. By default the command + returns a structured payload so the standard `-o json/yaml/table` formatters + handle output. Pass `--report` to + print a human-readable two-section report to stdout instead. +examples: + - name: Show status for all instances of a web app (JSON by default) + text: az webapp troubleshoot status --name MyWebApp --resource-group MyResourceGroup + - name: Print the human-readable report + text: az webapp troubleshoot status --name MyWebApp --resource-group MyResourceGroup --report + - name: Show status scoped to a single worker instance + text: az webapp troubleshoot status --name MyWebApp --resource-group MyResourceGroup --instance 7c2d9 +parameters: + - name: --instance + short-summary: Scope the report to a single worker instance. + long-summary: > + Accepts either the hex instanceId (from `az webapp list-instances`) or the + machine name (e.g. `lw0sdlwk0007AB`). When omitted, returns an overview of + every instance seen in the last 24 hours. + - name: --report + short-summary: Print a human-readable, color-coded report instead of returning structured data. + long-summary: > + When set, the command writes a formatted report (overview table plus + per-instance Last runtime status and Startup summary) to stdout and + returns no machine-readable output. Omit --report to keep the default + structured payload that works with `-o json`, `-o yaml`, and `-o table`. +""" + 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 7958d119c36..bda882a32d1 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -849,6 +849,13 @@ 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 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) + 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="Scope the report to a single worker instance. Accepts either the ARM instanceId or the machine name (e.g. `lw0sdlwk0007AB`). When omitted, returns an overview of every instance seen in the last 24 hours.") + c.argument('report', options_list=['--report'], arg_type=get_three_state_flag(), help="Print a human-readable, color-coded report to stdout instead of returning the structured payload.") + 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/_troubleshoot_status_report.py b/src/azure-cli/azure/cli/command_modules/appservice/_troubleshoot_status_report.py new file mode 100644 index 00000000000..02a3ccfc276 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/appservice/_troubleshoot_status_report.py @@ -0,0 +1,381 @@ +# -------------------------------------------------------------------------------------------- +# 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 status --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 re +import shutil +import sys +import textwrap +from datetime import datetime, timezone + +from azure.cli.core.style import Style, print_styled_text + + +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.""" + if not instances: + if not orphan_startups: + emit((Style.PRIMARY, "No per-instance runtime status was returned for '{}'. " + "Please visit the Azure Portal for further diagnosis.".format(app_name))) + return False + emit('') + emit((Style.PRIMARY, "No per-instance runtime status was returned for '{}', " + "but startup summaries were available. " + "Please visit the Azure Portal for further diagnosis.".format(app_name))) + emit('') + return True + emit('') + emit((Style.HIGHLIGHT, "Application status for {}.".format(app_name))) + emit('') + return True + + +def _emit_overview_table(instances, emit): + """Emit the overview table (one row per instance). Skip when we're only + rendering a single card (either only one instance total, or --instance filter).""" + col_widths = (14, 20, 12, 24) + header = "{:<{w0}}{:<{w1}}{:<{w2}}{}".format( + 'INSTANCE', 'MACHINE', 'STATE', 'UPDATED', + w0=col_widths[0], w1=col_widths[1], w2=col_widths[2]) + emit((Style.PRIMARY, header)) + emit((Style.PRIMARY, '-' * sum(col_widths))) + for inst in instances: + startup = inst.get('startup') or {} + updated = _format_dt(_most_recent_startup(startup)) or '-' + state = inst.get('state') or '-' + # Pad plain text first, then wrap the STATE segment in its style — the + # framework's color escapes don't perturb the visible column width. + emit([ + (Style.PRIMARY, '{:<{w}}'.format(_short_id(inst.get('instanceId')), w=col_widths[0])), + (Style.PRIMARY, '{:<{w}}'.format(inst.get('machineName') or '-', w=col_widths[1])), + (_state_style(state), '{:<{w}}'.format(state, w=col_widths[2])), + (Style.PRIMARY, updated), + ]) + emit('') + emit('') + + +def _emit_instance_section(inst, emit): + """Emit one per-instance card: header rule + Last runtime status + Startup summary.""" + machine = inst.get('machineName') + label = machine if machine else _short_id(inst.get('instanceId')) + scm_id = inst.get('startupInstanceId') + # When ARM's machineName and SCM's InstanceId disagree (common on Linux App + # Service — ARM tracks the worker slot, KuduLite tracks the container) + # surface both so users can correlate with SCM logs. + emit("-" * 76) + if scm_id and scm_id != machine: + emit('Instance {} Full Status Report (SCM: {}) '.format(label, scm_id)) + else: + emit('Instance {} Full Status Report '.format(label)) + emit("-" * 76) + emit((Style.HIGHLIGHT, 'Last runtime status')) + _print_runtime_block(inst, emit) + emit('') + emit((Style.HIGHLIGHT, 'Startup summary (last 24h)')) + if not machine and not inst.get('startup'): + # Without machineName we couldn't query KuduLite for this instance, so + # distinguish the "couldn't ask" case from "asked, nothing recorded". + emit(' Startup summary unavailable: machine name could not be determined for this instance.') + emit('') + else: + _print_startup_block(inst.get('startup'), emit) + emit() + emit() + + +def _emit_orphan_startup(orphan, emit): + """Emit an orphan-startup card: an SCM entry with no matching ARM instance. + Common cause: the container has been recycled — SCM still has the last + 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, 'Startup summary (last 24h)')) + _print_startup_block(orphan.get('Startup'), emit) + emit() + + +def _emit_hint_footer(instances, app_name, resource_group, emit): + """Emit the follow-up hint footer when at least one instance has a real failure + in the report's window (Failed > 0 AND lastError populated).""" + has_error = any( + (inst.get('lastError') and _failed_count(inst.get('startup')) > 0) + for inst in instances + ) + if not has_error: + return + rg = resource_group or '' + 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)) + + +def render_report(payload): + """Print the human-readable report (Site Runtime Status + per-instance Startup summary). + Invoked by 'az webapp troubleshoot status' when --report is passed.""" + instances = payload.get('instances') or [] + orphan_startups = payload.get('orphanStartups') or [] + app_name = payload.get('name') or '' + resource_group = payload.get('resourceGroup') + + def emit(*objs): + print_styled_text(*objs, file=sys.stdout) + + if not _emit_header(instances, orphan_startups, app_name, emit): + return + + if len(instances) > 1: + _emit_overview_table(instances, emit) + + for inst in instances: + _emit_instance_section(inst, emit) + + for orphan in orphan_startups: + _emit_orphan_startup(orphan, emit) + + _emit_hint_footer(instances, app_name, resource_group, emit) + + +def _state_style(state): + """Map a runtime state string to an azure-cli Style for print_styled_text.""" + if not state: + return Style.PRIMARY + s = state.lower() + if s == 'started': + return Style.SUCCESS + if s in ('stopped', 'failed', 'crashed', 'unhealthy'): + return Style.ERROR + if s in ('starting', 'pullingimage', 'pulling', 'pending'): + return Style.WARNING + return Style.PRIMARY + + +def _outcome_style(outcome): + if not outcome: + return Style.PRIMARY + o = outcome.upper() + if o == 'STARTED': + return Style.SUCCESS + if o in ('FAILED', 'CRASHED'): + return Style.ERROR + return Style.PRIMARY + + +def _count_style(count, kind): + """Style for a numeric count. kind='failed' -> ERROR when > 0; 'successful' -> SUCCESS when > 0. + Accepts either an int/str integer (e.g. 3, "3") or a KuduLite capped-count + string like "50+" (parsed as the leading integer for the > 0 test).""" + text = str(count) + try: + n = int(text) + except (TypeError, ValueError): + # Handle capped forms like "50+" — parse the leading digits. + m = re.match(r'\d+', text) + n = int(m.group(0)) if m else None + if n is None: + return Style.PRIMARY, text + if kind == 'failed' and n > 0: + return Style.ERROR, text + if kind == 'successful' and n > 0: + return Style.SUCCESS, text + return Style.PRIMARY, text + + +def _short_id(instance_id): + """Truncate a long hex ARM instanceId for table display.""" + if not instance_id: + return '-' + if len(instance_id) > 12: + return instance_id[:10] + return instance_id + + +def _format_dt(value): + if not value: + return None + # Pass through ISO strings; trim sub-second/timezone noise for the table view. + 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 '+' in v: + v = v.split('+', 1)[0] + return v + return str(value) + + +def _format_relative_delta(total_seconds): + if total_seconds < 0: + return 'in the future' + if total_seconds < 60: + return 'just now' + minutes = total_seconds // 60 + if minutes < 60: + return '{}m ago'.format(minutes) + hours = minutes // 60 + rem_min = minutes % 60 + if hours < 24: + return '{}h {}m ago'.format(hours, rem_min) if rem_min else '{}h ago'.format(hours) + days = hours // 24 + rem_hr = hours % 24 + return '{}d {}h ago'.format(days, rem_hr) if rem_hr else '{}d ago'.format(days) + + +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: + # datetime.fromisoformat pre-3.11 chokes on fractional seconds with 'Z' — strip both. + 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) + delta = datetime.now(timezone.utc) - dt + return _format_relative_delta(int(delta.total_seconds())) + + +def _failed_count(startup): + """Parse Startup.Failed into an int (0 when missing/invalid). Accepts int, + numeric string, or KuduLite's capped '50+' form (leading digits win).""" + if not startup: + return 0 + raw = startup.get('Failed') + if raw is None: + return 0 + text = str(raw) + try: + return int(text) + except ValueError: + m = re.match(r'\d+', text) + return int(m.group(0)) if m else 0 + + +def _emit_labeled(emit, label, value, value_style=None): + """Emit '