diff --git a/src/azure-cli/HISTORY.rst b/src/azure-cli/HISTORY.rst index 59fae05291c..c0cfcf63373 100644 --- a/src/azure-cli/HISTORY.rst +++ b/src/azure-cli/HISTORY.rst @@ -33,6 +33,7 @@ Release History * `az webapp list-runtimes`: Add `--runtime` and `--support` filter parameters (#32903) * [BREAKING CHANGE] `az webapp list-runtimes`: Remove deprecated `--linux` and `--show-runtime-details` parameters (#32903) * `az webapp log startup`: Add commands to list and view Linux container startup logs (#33256) +* `az webapp troubleshoot status`: Add new command group and command to show per-instance Site Runtime Status and recent startup summary for Linux web apps (#33673) * `az webapp create`: Add `--site-scoped-certs` parameter to support enabling or disabling site-scoped certificates (#33306) * `az webapp up`: Add warning message for future deprecation (#33410) * `az functionapp deployment source config-zip`: Fix `KeyError` `'FUNCTIONS_WORKER_RUNTIME'` for Go function apps on Flex Consumption (#33404) 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..07ec34270d6 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,49 @@ text: az webapp log startup show --name MyWebApp --resource-group MyResourceGroup --instance lw0sdlwk000002 """ +helps['webapp troubleshoot'] = """ +type: group +short-summary: Troubleshoot a web app. +""" + +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: ARM /siteStatus[/{instanceId}] (per-instance state, + action, last error, details). + - Startup summary: KuduLite (SCM) /api/startuplogs/summary (counts of + successful and failed startup attempts in the last 24h, plus earliest + and most recent). + + Use --instance to scope both to a single worker. By default returns the + structured payload (works with -o json/yaml/tsv/table). Pass --report to + print a human-readable, color-coded report 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 Site 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/yaml/tsv/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..401399dd822 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. Default to the production slot if not specified") + c.argument('instance', options_list=['--instance']) + c.argument('report', options_list=['--report'], arg_type=get_three_state_flag()) + 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 15462365f96..93353a3c84a 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,46 @@ def transform_runtime_list_output(result): ]) for r in result] +def transform_troubleshoot_status_output(result): + """Flatten the nested `instances` payload into one row per worker for `-o table`. + Column layout: InstanceId / State / (LastError / LastErrorTimestamp only when + any row has an error) / Successful / Failed / Updated. + + The framework's default table renderer would only surface top-level scalars + (name, resourceGroup) and drop every meaningful field.""" + from collections import OrderedDict + from .custom import _format_dt, _most_recent_startup + + items = (result or {}).get('instances') or [] + # LastError* columns are only surfaced when at least one instance reports one, + # so healthy fleets get a compact 5-column table instead of a wide 7-column + # one full of empty cells. + show_errors = any(item.get('lastError') for item in items) + + rows = [] + for item in items: + startup = item.get('startup') or {} + # KuduLite returns SummaryFetchError when it couldn't read this worker's log + # directory; count/timestamp fields are meaningless in that case. + has_startup_error = bool(startup.get('SummaryFetchError')) + successful = None if has_startup_error else startup.get('Successful') + failed = None if has_startup_error else startup.get('Failed') + updated = None if has_startup_error else _format_dt(_most_recent_startup(startup)) + + row = OrderedDict([ + ('InstanceId', item.get('instanceId')), + ('State', item.get('state')), + ]) + if show_errors: + row['LastError'] = item.get('lastError') + row['LastErrorTimestamp'] = item.get('lastErrorTimestamp') + row['Successful'] = successful + row['Failed'] = failed + row['Updated'] = updated + rows.append(row) + return rows + + def ex_handler_factory(creating_plan=False): def _ex_handler(ex): ex = _polish_bad_errors(ex, creating_plan) @@ -259,6 +299,10 @@ def load_command_table(self, _): g.custom_command('list', 'list_startup_logs') g.custom_show_command('show', 'show_startup_log') + with self.command_group('webapp troubleshoot', is_preview=True) as g: + g.custom_command('status', 'troubleshoot_status', + table_transformer=transform_troubleshoot_status_output) + 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 31d333d7d28..8f1a1c67a64 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -5,6 +5,8 @@ import ast import base64 +import os +import sys import threading import time import re @@ -385,10 +387,12 @@ def create_webapp(cmd, resource_group_name, name, plan, runtime=None, startup_fi _enable_basic_auth(cmd, name, None, resource_group_name, basic_auth.lower()) # Only suggest deployment command when no deployment method is already configured - if not using_webapp_up and not any([container_image_name, deployment_container_image_name, - multicontainer_config_type, sitecontainers_app, - deployment_source_url, deployment_local_git]): - logger.warning("Webapp '%s' created. Deploy your code with: az webapp deploy", name) + if not using_webapp_up: + if not any([container_image_name, deployment_container_image_name, + 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_status_tip(name, resource_group_name, is_linux) return webapp @@ -2460,6 +2464,28 @@ def show_app(cmd, resource_group_name, name, slot=None): return app +def _log_webapp_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. + if not is_linux: + return + logger.warning("Tip: run 'az webapp status --name %s --resource-group %s' " + "to see per-instance runtime status.", + name, resource_group_name) + + +def _extract_webapp_status_items(result): + # The siteStatus response holds per-instance status under 'properties': + # a list for /siteStatus, a single object for /siteStatus/{instanceId}. + # Normalize both shapes into a list for uniform formatting. + properties = result.get('properties') + if isinstance(properties, list): + return properties + if isinstance(properties, dict): + return [properties] + return [] + + def _list_app(cli_ctx, resource_group_name=None, show_details=False): client = web_client_factory(cli_ctx) if resource_group_name: @@ -6412,6 +6438,11 @@ def list_deployment_logs(cmd, resource_group, name, slot=None): def _ensure_linux_webapp_for_startup_logs(cmd, resource_group, name, slot=None): + _ensure_linux_webapp(cmd, resource_group, name, slot, + command_label="'az webapp log startup'") + + +def _ensure_linux_webapp(cmd, resource_group, name, slot=None, command_label='This command'): client = web_client_factory(cmd.cli_ctx) if slot: app = client.web_apps.get_slot(resource_group, name, slot) @@ -6419,7 +6450,7 @@ def _ensure_linux_webapp_for_startup_logs(cmd, resource_group, name, slot=None): app = client.web_apps.get(resource_group, name) if app is None or not is_linux_webapp(app): raise ArgumentUsageError( - "'az webapp log startup' is only supported for Linux web apps.") + "{} is only supported for Linux web apps.".format(command_label)) def list_startup_logs(cmd, resource_group, name, slot=None, outcome=None, instance=None): @@ -6516,6 +6547,381 @@ def show_startup_log(cmd, resource_group, name, slot=None, filename=None, instan return response.json() +# --------------------------------------------------------------------------- +# az webapp troubleshoot status +# --------------------------------------------------------------------------- + +def troubleshoot_status(cmd, resource_group, name, slot=None, instance=None, report=False): + """Fetch runtime + startup status for a Linux web app. + + Data sources: + * Site Runtime Status comes from ARM: + GET /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Web + /sites/{name}[/slots/{slot}]/siteStatus[/{instanceId}]?api-version=... + * Startup summary comes from KuduLite (SCM): + GET https://{scm-host}/api/startuplogs/summary[?instance={id}] + + By default returns the structured payload (list of instances + startup + summary) so the standard `-o json/yaml/tsv/table` formatters handle output. + Pass --report to print the human-readable report to stdout instead. + """ + import requests + from azure.cli.core.commands.client_factory import get_subscription_id + from azure.core.exceptions import HttpResponseError as _Hre + + _ensure_linux_webapp(cmd, resource_group, name, slot, + command_label="'az webapp troubleshoot status'") + + # --- 1. Map ARM hex instanceId <-> friendly machineName via ARM /instances. + # We fetch this first so we can accept either form on --instance and resolve + # the right value before calling /siteStatus (ARM) and /api/startuplogs/summary (SCM). + client = web_client_factory(cmd.cli_ctx) + subscription_id = get_subscription_id(cmd.cli_ctx) + api_version = client._config.api_version + slot_segment = '/slots/{}'.format(slot) if slot else '' + instances_url = ( + '{rm}/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Web' + '/sites/{name}{slot_seg}/instances?api-version={ver}' + ).format( + rm=cmd.cli_ctx.cloud.endpoints.resource_manager, + sub=subscription_id, rg=resource_group, name=name, + slot_seg=slot_segment, ver=api_version) + id_to_machine = {} + machine_to_id = {} + try: + instances_payload = send_raw_request(cmd.cli_ctx, 'GET', instances_url).json() + for entry in instances_payload.get('value') or []: + entry_name = entry.get('name') + machine = (entry.get('properties') or {}).get('machineName') + if entry_name and machine: + id_to_machine[entry_name] = machine + machine_to_id[machine] = entry_name + except _Hre as ex: + logger.warning("Failed to retrieve machine names from '%s': %s", instances_url, ex) + + # Resolve --instance: accept either hex GUID (ARM form) or machineName (SCM form). + arm_instance_id = instance + if instance and instance in machine_to_id: + arm_instance_id = machine_to_id[instance] + elif instance and instance not in id_to_machine and machine_to_id: + # User passed something that matches neither known id nor machineName. + raise ResourceNotFoundError( + "Instance '{}' was not found for this webapp. " + "Run 'az webapp list-instances' to see available instance IDs.".format(instance)) + + # --- 2. Site Runtime Status from ARM /siteStatus[/{hex-instanceId}] --- + instance_segment = '/{}'.format(arm_instance_id) if arm_instance_id else '' + arm_url = ( + '{rm}/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Web' + '/sites/{name}{slot_seg}/siteStatus{inst_seg}?api-version={ver}' + ).format( + rm=cmd.cli_ctx.cloud.endpoints.resource_manager, + sub=subscription_id, rg=resource_group, name=name, + slot_seg=slot_segment, inst_seg=instance_segment, ver=api_version) + + try: + arm_response = send_raw_request(cmd.cli_ctx, 'GET', arm_url).json() + except _Hre as ex: + if instance and getattr(ex, 'status_code', None) == 404: + raise ResourceNotFoundError( + "Instance '{}' was not found for this webapp. " + "Run 'az webapp list-instances' to see available instance IDs.".format(instance)) + raise + + runtime_items = _extract_webapp_status_items(arm_response) + for item in runtime_items: + machine = id_to_machine.get(item.get('instanceId')) + if machine: + item['machineName'] = machine + + # --- 3. Per-instance Startup summary from SCM /api/startuplogs/summary --- + # KuduLite's summary endpoint already returns one entry per instance + # ([{InstanceId, Startup}, ...]) for everything seen in the last 24h, so we + # make a single round-trip and pair the entries with runtime_items locally + # instead of fanning out one call per instance. + scm_url = _get_scm_url(cmd, resource_group, name, slot) + headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group, slot) + + # When --instance was supplied we still pass the filter to KuduLite so it + # only walks the requested worker's log directory (and the response stays + # small). Otherwise we fetch the full set in one call. + target_machine = None + if instance and len(runtime_items) == 1: + target_machine = runtime_items[0].get('machineName') + + summary_url = '{}/api/startuplogs/summary'.format(scm_url) + if target_machine: + summary_url = '{}?instance={}'.format(summary_url, quote(target_machine, safe='')) + + startup_by_machine = {} + try: + summary_response = requests.get(summary_url, headers=headers, timeout=30) + except requests.RequestException as ex: + logger.warning("Failed to call '%s': %s", summary_url, ex) + summary_response = None + + if summary_response is not None: + if summary_response.status_code == 200: + try: + body = summary_response.json() + except ValueError: + body = None + if isinstance(body, list): + for entry in body: + if not isinstance(entry, dict): + continue + # KuduLite serializes its C# POCO with default settings -> PascalCase + # (InstanceId, Startup, Successful, MostRecent, ...). When KuduLite + # ships a [JsonPropertyName] / camelCase contract, switch the keys + # here (and in _print_startup_block / overview-table renderer) accordingly. + key = entry.get('InstanceId') + if not key: + continue + startup_by_machine[key] = entry.get('Startup') or entry + elif isinstance(body, dict): + # Tolerate a single-object response (older KuduLite shape). + inner = body.get('Startup') or body + key = body.get('InstanceId') or target_machine + if key: + startup_by_machine[key] = inner + elif summary_response.status_code == 404: + # No startup logs in window for any instance, or endpoint not rolled out. + pass + else: + logger.warning( + "Failed to retrieve startup summary (status %s %s).", + summary_response.status_code, summary_response.reason) + + for item in runtime_items: + machine = item.get('machineName') + # Without machineName we can't correlate this runtime entry to a KuduLite + # entry (KuduLite keys its summaries by machine name). + item['startup'] = startup_by_machine.get(machine) if machine else None + + # --- 3. Assemble payload --- + payload = { + 'name': name, + 'resourceGroup': resource_group, + 'instances': runtime_items, + } + if report: + _render_troubleshoot_status(payload) + return None + return payload + + +def _render_troubleshoot_status(payload): + """Print the human-readable report (Site Runtime Status + per-instance Startup summary). + Invoked by 'az webapp troubleshoot status' when --report is passed.""" + from azure.cli.core.style import Style, print_styled_text + + instances = payload.get('instances') or [] + app_name = payload.get('name') or '' + resource_group = payload.get('resourceGroup') + + def _out(*objs): + print_styled_text(*objs, file=sys.stdout) + + if not instances: + _out("No runtime status reported for '{}'.".format(app_name)) + return + + _out('') + _out((Style.HIGHLIGHT, "Application status for {}.".format(app_name))) + _out('') + + # Overview table (skip when only one instance is present, e.g. --instance filter). + if len(instances) > 1: + 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]) + _out((Style.HIGHLIGHT, header)) + _out('-' * 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. + _out([ + (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), + ]) + _out('') + _out('') + + # Per-instance Site Runtime Status + Startup summary. + for inst in instances: + machine = inst.get('machineName') + label = machine if machine else _short_id(inst.get('instanceId')) + sep = '-' * 66 + _out(sep) + _out((Style.HIGHLIGHT, 'Instance {} Full Status Report'.format(label))) + _out(sep) + _out((Style.HIGHLIGHT, 'Last runtime status')) + _print_runtime_block(inst, _out) + _out('') + _out((Style.HIGHLIGHT, 'Startup summary (last 24h)')) + if not machine: + # Without machineName we couldn't query KuduLite for this instance, so + # distinguish the "couldn't ask" case from "asked, nothing recorded". + _out(' Startup summary unavailable: machine name could not be determined for this instance.') + _out('') + else: + _print_startup_block(inst.get('startup'), _out) + + # Hint footer — only surfaced when at least one instance reports an error detail. + has_error = any( + (inst.get('lastErrorDetails') or '').strip() for inst in instances + ) + if has_error: + rg = resource_group or '' + _out((Style.WARNING, 'Hint:')) + _out(' Check application logs: az webapp log tail -n {} -g {}'.format(app_name, rg)) + _out(' Check startup logs: az webapp log startup show -n {} -g {}'.format(app_name, rg)) + + +def _state_style(state): + """Map a runtime state string to an azure-cli Style for print_styled_text.""" + from azure.cli.core.style import Style + 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): + from azure.cli.core.style import Style + 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).""" + from azure.cli.core.style import Style + text = str(count) + try: + n = int(text) + except (TypeError, ValueError): + # Handle capped forms like "50+" — parse the leading digits. + import re as _re + 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 _print_runtime_block(inst, emit): + """Print one Site Runtime Status block from an ARM /siteStatus item.""" + from azure.cli.core.style import Style + if not inst: + emit(' (no runtime status reported)') + return + state = inst.get('state') or '-' + details = inst.get('details') or '-' + last_error = inst.get('lastError') or '-' + last_error_details = inst.get('lastErrorDetails') or '-' + # Treat .NET DateTime.MinValue (0001-01-01...) as "no error ever" and hide it. + last_error_ts_raw = inst.get('lastErrorTimestamp') + if isinstance(last_error_ts_raw, str) and last_error_ts_raw.startswith('0001-01-01'): + last_error_ts_raw = None + last_error_ts = _format_dt(last_error_ts_raw) or '-' + emit([(Style.PRIMARY, ' State '), (_state_style(state), state)]) + emit(' Details {}'.format(details)) + emit(' Last Error {}'.format(last_error)) + emit(' Last Error Details {}'.format(last_error_details)) + emit(' Last Error Timestamp {}'.format(last_error_ts)) + + +def _most_recent_startup(startup): + """Return the most recent of MostRecentSuccess / MostRecentFailure (ISO strings), + or None if both are missing. Lexicographic max is correct for RFC3339/ISO-8601 UTC.""" + if not startup: + return None + candidates = [ts for ts in (startup.get('MostRecentSuccess'), + startup.get('MostRecentFailure')) if ts] + return max(candidates) if candidates else None + + +def _print_startup_block(s, emit): + from azure.cli.core.style import Style + if not s: + emit(' No startup attempts recorded in the last 24 hours') + emit('') + return + # KuduLite sets SummaryFetchError when it couldn't read the log directory for + # this worker (e.g. log file too large). All other fields are meaningless then. + error = s.get('SummaryFetchError') + if error: + emit([(Style.PRIMARY, ' Startup summary unavailable: '), + (Style.WARNING, str(error))]) + emit('') + return + successful = s.get('Successful', 0) + failed = s.get('Failed', 0) + emit([(Style.PRIMARY, ' Successful '), _count_style(successful, 'successful')]) + emit([(Style.PRIMARY, ' Failed '), _count_style(failed, 'failed')]) + most_recent_success = _format_dt(s.get('MostRecentSuccess')) + most_recent_failure = _format_dt(s.get('MostRecentFailure')) + if most_recent_success: + emit([(Style.PRIMARY, ' Most recent success '), + (_outcome_style('STARTED'), most_recent_success)]) + if most_recent_failure: + emit([(Style.PRIMARY, ' Most recent failure '), + (_outcome_style('FAILED'), most_recent_failure)]) + emit('') + + 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) @@ -9931,6 +10337,7 @@ def _poll_deployment_runtime_status(cmd, resource_group_name, webapp_name, slot, time_elapsed = 0 deployment_status = None response_body = None + status_tip_logged = False while time_elapsed < max_time_sec: try: response_body = send_raw_request(cmd.cli_ctx, "GET", deploymentstatusapi_url).json() @@ -9945,12 +10352,19 @@ def _poll_deployment_runtime_status(cmd, resource_group_name, webapp_name, slot, status = deployment_status if status is None else status logger.warning("Status: %s Time: %s(s)", status, time_elapsed) if deployment_status == "RuntimeStarting": + if not status_tip_logged: + _log_webapp_status_tip(webapp_name, resource_group_name, True) + status_tip_logged = True logger.info("InprogressInstances: %s, SuccessfulInstances: %s", deployment_properties.get('numberOfInstancesInProgress'), deployment_properties.get('numberOfInstancesSuccessful')) if deployment_status == "RuntimeSuccessful": + if not status_tip_logged: + _log_webapp_status_tip(webapp_name, resource_group_name, True) break if deployment_status == "RuntimeFailed": + if not status_tip_logged: + _log_webapp_status_tip(webapp_name, resource_group_name, True) error_text = "" total_num_instances = int(deployment_properties.get('numberOfInstancesInProgress')) + \ int(deployment_properties.get('numberOfInstancesSuccessful')) + \ @@ -10834,6 +11248,8 @@ def webapp_up(cmd, name=None, resource_group_name=None, plan=None, location=None logger.warning("You can launch the app at %s", _url) create_json.update({'URL': _url}) + _log_webapp_status_tip(name, rg_name, _is_linux) + if logs: _configure_default_logging(cmd, rg_name, name) try: 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 cc19fbcb865..0e5b68720eb 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 @@ -13,7 +13,8 @@ from azure.cli.core.azclierror import (InvalidArgumentValueError, MutuallyExclusiveArgumentError, ArgumentUsageError, - AzureResponseError) + AzureResponseError, + ResourceNotFoundError) from azure.cli.command_modules.appservice.custom import (set_deployment_user, update_git_token, add_hostname, update_site_configs, @@ -37,6 +38,7 @@ update_webapp, list_startup_logs, show_startup_log, + troubleshoot_status, create_webapp) # pylint: disable=line-too-long @@ -968,6 +970,273 @@ def test_show_startup_log_404_with_instance(self, requests_get_mock, _scm_url_mo self.assertEqual(logger_mock.warning.call_args[0][1], 'lw0sdlwk000002') +class TestTroubleshootStatusMocked(unittest.TestCase): + """Tests for az webapp troubleshoot status (ARM siteStatus + SCM startuplogs/summary).""" + + def setUp(self): + is_linux_patch = mock.patch( + 'azure.cli.command_modules.appservice.custom.is_linux_webapp', + return_value=True) + client_factory_patch = mock.patch( + 'azure.cli.command_modules.appservice.custom.web_client_factory') + sub_id_patch = mock.patch( + 'azure.cli.core.commands.client_factory.get_subscription_id', + return_value='00000000-0000-0000-0000-000000000000') + self.client_factory_mock = client_factory_patch.start() + is_linux_patch.start() + sub_id_patch.start() + self.addCleanup(is_linux_patch.stop) + self.addCleanup(client_factory_patch.stop) + self.addCleanup(sub_id_patch.stop) + # Pin API version reported by the SDK config so URL assertions are stable. + self.client_factory_mock.return_value._config.api_version = '2025-05-01' + + self.cmd = _get_test_cmd() + self.cmd.cli_ctx.cloud.endpoints.resource_manager = 'https://management.azure.com' + + @staticmethod + def _arm_response(items): + return {'properties': items} + + @staticmethod + def _instances_payload(mapping): + """Build an ARM /instances response from {hex_id: machineName} mapping.""" + return {'value': [{'name': hex_id, 'properties': {'machineName': mn}} + for hex_id, mn in mapping.items()]} + + @staticmethod + def _make_response(status_code=200, json_data=None, reason=''): + resp = mock.MagicMock() + resp.status_code = status_code + resp.reason = reason + resp.json.return_value = json_data + return resp + + @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_all_instances(self, requests_get_mock, send_raw_request_mock, + _scm_url_mock, _headers_mock): + arm_items = [ + {'instanceId': 'a3f1b', 'state': 'Started', 'action': 'SiteStarted', + 'lastError': None, 'lastErrorDetails': None, 'lastErrorTimestamp': None, + 'details': 'Site is running', 'detailsLevel': 'Information'}, + {'instanceId': 'b4d22', 'state': 'Starting', 'action': 'PullingImage', + 'lastError': None, 'lastErrorDetails': None, 'lastErrorTimestamp': None, + 'details': 'Pulling image', 'detailsLevel': 'Warning'}, + ] + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'a3f1b': 'lw0sdlwk0008PB', 'b4d22': 'lw1sdlwk0009EF'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_items))), + ] + # Real KuduLite response is a single list with one entry per instance. + a3f1b_startup = {'Successful': 1, 'Failed': 0} + b4d22_startup = {'Successful': 0, 'Failed': 3} + requests_get_mock.return_value = self._make_response(200, json_data=[ + {'InstanceId': 'lw0sdlwk0008PB', 'Startup': a3f1b_startup}, + {'InstanceId': 'lw1sdlwk0009EF', 'Startup': b4d22_startup}, + ]) + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp') + + self.assertEqual(result['instances'][0]['startup'], a3f1b_startup) + self.assertEqual(result['instances'][1]['startup'], b4d22_startup) + self.assertEqual(result['instances'][0]['machineName'], 'lw0sdlwk0008PB') + self.assertEqual(result['instances'][1]['machineName'], 'lw1sdlwk0009EF') + # ARM calls: instances FIRST (so we can resolve --instance), then siteStatus. + arm_urls = [call.args[2] for call in send_raw_request_mock.call_args_list] + self.assertEqual(arm_urls, [ + 'https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000' + '/resourceGroups/myRG/providers/Microsoft.Web/sites/myApp/instances?api-version=2025-05-01', + 'https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000' + '/resourceGroups/myRG/providers/Microsoft.Web/sites/myApp/siteStatus?api-version=2025-05-01', + ]) + # Single unfiltered SCM call returns every instance in one response. + requests_get_mock.assert_called_once_with( + 'https://myapp.scm.azurewebsites.net/api/startuplogs/summary', + headers={'Authorization': 'Bearer token'}, + 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_single_instance(self, requests_get_mock, send_raw_request_mock, + _scm_url_mock, _headers_mock): + arm_item = {'instanceId': '7c2d9', 'state': 'Stopped', 'action': 'SiteStopped', + 'lastError': 'NoResponse', 'lastErrorDetails': 'Worker not reachable', + 'lastErrorTimestamp': '2026-05-20T18:50:44Z', + 'details': 'Stopped', 'detailsLevel': 'Error'} + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'7c2d9': 'lw0sdlwk0007AB'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_item))), + ] + startup_summary = {'Successful': 0, 'Failed': 4} + requests_get_mock.return_value = self._make_response( + 200, json_data=[{'InstanceId': 'lw0sdlwk0007AB', 'Startup': startup_summary}]) + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp', instance='7c2d9') + + self.assertEqual(result['instances'][0]['instanceId'], '7c2d9') + self.assertEqual(result['instances'][0]['startup'], startup_summary) + self.assertEqual(result['instances'][0]['machineName'], 'lw0sdlwk0007AB') + arm_urls = [call.args[2] for call in send_raw_request_mock.call_args_list] + self.assertEqual(arm_urls, [ + 'https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000' + '/resourceGroups/myRG/providers/Microsoft.Web/sites/myApp/instances?api-version=2025-05-01', + 'https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000' + '/resourceGroups/myRG/providers/Microsoft.Web/sites/myApp/siteStatus/7c2d9' + '?api-version=2025-05-01', + ]) + requests_get_mock.assert_called_once_with( + 'https://myapp.scm.azurewebsites.net/api/startuplogs/summary?instance=lw0sdlwk0007AB', + headers={'Authorization': 'Bearer token'}, + 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_summary_404_returns_empty_startup( + self, requests_get_mock, send_raw_request_mock, _scm_url_mock, _headers_mock): + arm_items = [{'instanceId': 'abcde', 'state': 'Started', 'action': 'SiteStarted'}] + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'abcde': 'lw0sdlwk0001AA'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_items))), + ] + requests_get_mock.return_value = self._make_response(404) + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp') + + self.assertIsNone(result['instances'][0]['startup']) + + @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') + def test_troubleshoot_status_arm_404_with_instance(self, send_raw_request_mock, + _scm_url_mock, _headers_mock): + error = HttpResponseError(message='Not found') + error.status_code = 404 + send_raw_request_mock.side_effect = error + + with self.assertRaises(ResourceNotFoundError): + troubleshoot_status(self.cmd, 'myRG', 'myApp', instance='7c2d9') + + @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_summary_500_logs_warning( + self, requests_get_mock, send_raw_request_mock, _scm_url_mock, _headers_mock): + arm_items = [{'instanceId': 'abcde', 'state': 'Started', 'action': 'SiteStarted'}] + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'abcde': 'lw0sdlwk0001AA'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_items))), + ] + requests_get_mock.return_value = self._make_response(500, reason='Internal Server Error') + + with mock.patch('azure.cli.command_modules.appservice.custom.logger') as logger_mock: + result = troubleshoot_status(self.cmd, 'myRG', 'myApp') + + self.assertIsNone(result['instances'][0]['startup']) + warn_msgs = [c[0][0] for c in logger_mock.warning.call_args_list] + self.assertTrue(any('startup summary' in m for m in warn_msgs)) + + @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_machine_name_as_instance( + self, requests_get_mock, send_raw_request_mock, _scm_url_mock, _headers_mock): + """User passes a friendly machineName for --instance; we should resolve it to + the hex ARM instanceId before calling /siteStatus.""" + arm_item = {'instanceId': '7c2d9', 'state': 'Started', 'action': 'SiteStarted'} + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'7c2d9': 'lw0sdlwk0007AB'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_item))), + ] + requests_get_mock.return_value = self._make_response( + 200, json_data=[{'InstanceId': 'lw0sdlwk0007AB', + 'Startup': {'Successful': 1, 'Failed': 0}}]) + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp', instance='lw0sdlwk0007AB') + + # ARM /siteStatus must use the hex id even though user passed the machine name. + arm_urls = [call.args[2] for call in send_raw_request_mock.call_args_list] + self.assertIn('/siteStatus/7c2d9?', arm_urls[1]) + self.assertEqual(result['instances'][0]['machineName'], 'lw0sdlwk0007AB') + + @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') + def test_troubleshoot_status_unknown_instance_raises( + self, send_raw_request_mock, _scm_url_mock, _headers_mock): + """User passes an --instance that matches neither hex id nor machineName.""" + send_raw_request_mock.return_value = mock.MagicMock( + json=mock.MagicMock(return_value=self._instances_payload({'7c2d9': 'lw0sdlwk0007AB'}))) + + with self.assertRaises(ResourceNotFoundError): + troubleshoot_status(self.cmd, 'myRG', 'myApp', instance='does-not-exist') + + def test_troubleshoot_status_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_status(self.cmd, 'myRG', 'myWindowsApp') + self.assertIn('Linux', 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('azure.cli.command_modules.appservice.custom.send_raw_request') + @mock.patch('requests.get') + @mock.patch('azure.cli.command_modules.appservice.custom._render_troubleshoot_status') + def test_troubleshoot_status_report_flag_renders_and_returns_none( + self, render_mock, requests_get_mock, send_raw_request_mock, + _scm_url_mock, _headers_mock): + """With --report, command calls the renderer and returns None (no structured payload).""" + arm_item = {'instanceId': '7c2d9', 'state': 'Running'} + send_raw_request_mock.side_effect = [ + mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload( + {'7c2d9': 'lw0sdlwk0007AB'}))), + mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response(arm_item))), + ] + requests_get_mock.return_value = self._make_response( + 200, json_data=[{'InstanceId': 'lw0sdlwk0007AB', + 'Startup': {'Successful': 1, 'Failed': 0}}]) + + result = troubleshoot_status(self.cmd, 'myRG', 'myApp', report=True) + + self.assertIsNone(result) + render_mock.assert_called_once() + rendered_payload = render_mock.call_args.args[0] + self.assertEqual(rendered_payload['name'], 'myApp') + self.assertEqual(rendered_payload['instances'][0]['instanceId'], '7c2d9') + + class TestRuntimeFailedHintMocked(unittest.TestCase): """Tests that the TIP hint appears in RuntimeFailed and timeout errors."""