Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6ed2900
first push
aamos-company Jul 7, 2026
40d2042
output updates, nest config check
aamos-company Jul 8, 2026
b27ee47
troubleshoot config: friendly retry message on SCM failure in --repor…
aamos-company Jul 10, 2026
ee5dc83
troubleshoot config: shorter failure message
aamos-company Jul 10, 2026
27e1b3b
troubleshoot config: drop trailing blank line when runtime section no…
aamos-company Jul 10, 2026
e8f3277
troubleshoot config: report polish
aamos-company Jul 10, 2026
176158e
troubleshoot config: retries, DetailsLevel, wrapping, hints
aamos-company Jul 10, 2026
cb40b5d
troubleshoot config: drop legacy IssueDetected fallback
aamos-company Jul 10, 2026
b3ab3ea
troubleshoot config: gate runtime section on timestamp only
aamos-company Jul 10, 2026
c20a2cd
troubleshoot config: print 'Feature is currently unavailable.' on SCM…
aamos-company Jul 10, 2026
7433270
troubleshoot config: address PR review
aamos-company Jul 13, 2026
d5c3ed1
troubleshoot config: extract --report rendering into helper module
aamos-company Jul 13, 2026
0e2d65d
troubleshoot config: show Instance + failing count in runtime error s…
aamos-company Jul 13, 2026
2118219
troubleshoot config: document the 15-minute runtime error freshness g…
aamos-company Jul 13, 2026
61d00a7
troubleshoot config: strip internal plumbing fields from JSON payload
aamos-company Jul 13, 2026
9473918
troubleshoot config: rename SCM 404 message to name the specific feature
aamos-company Jul 13, 2026
ba91678
troubleshoot config: address PR #33709 review feedback
aamos-company Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/azure-cli/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 config`: Add preview command that runs built-in configuration checks against a Linux web app and surfaces the last runtime error from ARM (#33709)
* `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)
Expand Down
29 changes: 29 additions & 0 deletions src/azure-cli/azure/cli/command_modules/appservice/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -2432,6 +2432,35 @@
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 the last 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 most recent site runtime status error reported by App Service.
The runtime error recommendation section is only surfaced when the
error occurred within the last 15 minutes; older errors are still
included in the structured payload but are hidden from the `--report`
view.

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: Run the built-in configuration checks and show the 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
"""

helps['functionapp log'] = """
type: group
short-summary: Manage function app logs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 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('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('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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
# --------------------------------------------------------------------------------------------
# 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'.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Examples:
Note: there is a PR out that will improve the suggestions for the last error details

  • Built in checks is unavailable but runtime status still shows (if the error time is within the last 15 mins)
Image
  • Built in checks available (**test data, not actual value. Corresponding Json from Kudu below). If any of the details level is error, hints will be shown
Image Image
  • Built in checks + site runtime recommendation
Image


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 '+' 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)
delta = datetime.now(timezone.utc) - dt
total_seconds = int(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_hours = hours % 24
return '{}d {}h ago'.format(days, rem_hours) if rem_hours else '{}d ago'.format(days)


def render_report(payload):
"""Print the human-readable report: BUILT-IN CHECKS + SITE RUNTIME ERROR
RECOMMENDATION. Invoked when ``--report`` is passed."""

def _out(*objs):
print_styled_text(*objs, file=sys.stdout)

def _row(*objs):
_out(list(objs))

def _labeled(label, value):
"""Emit '<label><value>' with hanging indent so wrapped continuation
lines align under the value column."""
text = '' if value is None else str(value)
term_w = shutil.get_terminal_size(fallback=(120, 40)).columns
indent = ' ' * len(label)
body_w = max(20, term_w - len(label))
lines = textwrap.wrap(text, width=body_w) or [text]
_row((Style.PRIMARY, label), (Style.PRIMARY, lines[0]))
for cont in lines[1:]:
_row((Style.PRIMARY, indent), (Style.PRIMARY, cont))

config_check = payload.get('configCheck') or {}
config_check_failed = payload.get('configCheck') is None
config_check_status = payload.get('configCheckStatus')
settings = config_check.get('Settings') or config_check.get('settings') or []
if not isinstance(settings, list):
settings = []
settings = [s for s in settings if isinstance(s, dict)]
runtime_error = payload.get('runtimeError')

def _details_level(setting):
# KuduLite tags each check with DetailsLevel: 'info' | 'warning' | 'error'.
raw = setting.get('DetailsLevel')
if raw is None:
raw = setting.get('detailsLevel')
if isinstance(raw, str):
level = raw.strip().lower()
if level in ('info', 'warning', 'error'):
return level
return 'info'

def _style_for_level(level):
if level == 'error':
return Style.ERROR
if level == 'warning':
return Style.WARNING
return Style.SUCCESS

def _is_issue(level):
return level in ('warning', 'error')

any_issue = any(_is_issue(_details_level(s)) for s in settings)

# Show the runtime error section only when the ARM lastErrorTimestamp is
# within the freshness window. The payload already carries the pre-computed
# 'isRecent' signal so structured-payload consumers can apply the same gate
# (see _RUNTIME_ERROR_FRESHNESS_MINUTES / _runtime_error_is_recent in custom.py).
show_runtime = bool(runtime_error and runtime_error.get('isRecent'))

# ---- Section 1: Built-in checks ----
_out()
_row((Style.HIGHLIGHT, '═══ BUILT-IN CHECKS ' + '═' * 55))
_out()
if config_check_failed:
if config_check_status == 404:
_row((Style.WARNING, 'Configuration check feature is currently unavailable.'))
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.'))
elif not settings:
_row((Style.WARNING, 'No built-in configuration checks reported.'))
else:
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_v = str(setting.get('Setting') or '')
value_v = str(setting.get('Value') if setting.get('Value') is not None else '')
details_v = str(setting.get('Details') or '')
details_style = _style_for_level(_details_level(setting))
prefix = '{s:<{sw}}{v:<{vw}}'.format(
s=name_v, sw=setting_w, v=value_v, vw=value_w)
details_w = max(20, term_w - len(prefix))
detail_lines = textwrap.wrap(details_v, width=details_w) or [details_v]
_row((Style.PRIMARY, prefix), (details_style, detail_lines[0]))
cont_indent = ' ' * len(prefix)
for cont in detail_lines[1:]:
_row((Style.PRIMARY, cont_indent), (details_style, cont))

# ---- Section 2: Site runtime error recommendation ----
# Rendered only when the ARM lastErrorTimestamp is within the last 15
# minutes. Applied consistently regardless of whether the built-in
# checks succeeded, failed, or reported no issues.
if show_runtime:
_out()
_out()
_row((Style.HIGHLIGHT, '═══ SITE RUNTIME ERROR RECOMMENDATION ' + '═' * 37))
_out()
if runtime_error is None:
_row((Style.PRIMARY, 'No runtime error reported.'))
else:
state = str(runtime_error.get('state') or '')
last_error = str(runtime_error.get('lastError') or '')
details = str(runtime_error.get('lastErrorDetails') or runtime_error.get('details') or '')
timestamp_raw = runtime_error.get('lastErrorTimestamp')
timestamp = _format_dt(timestamp_raw) or str(timestamp_raw or '')
if timestamp:
age = _relative_age(timestamp_raw if isinstance(timestamp_raw, str) else None)
if age:
timestamp = '{} ({})'.format(timestamp, age)

instance_short = _short_id(runtime_error.get('instanceId'))
failing_count = runtime_error.get('failingInstanceCount')
if instance_short:
if isinstance(failing_count, int) and failing_count >= 1:
instance_value = '{} (1 out of {} failing instances)'.format(
instance_short, failing_count)
else:
instance_value = instance_short
_labeled('Instance ', instance_value)
if state:
_labeled('State ', state)
if last_error:
_labeled('Last Error ', last_error)
if details:
_labeled('Last Error Details ', details)
if timestamp:
_labeled('Last Error Timestamp ', timestamp)

_out()

# ---- Section 3: Suggested next steps ----
# When any built-in check flagged a warning/error, point the user at the
# az commands they'll actually need to remediate: update the app setting
# and check application logs. Rendered in the same "Hint:" style used by
# the status command's failure footer.
if any_issue:
rg = payload.get('resourceGroup') or '<resource-group>'
site_name = payload.get('name') or '<site-name>'
_out()
_out((Style.WARNING, '▶ Hint:'))
_out(' Update flagged app setting: az webapp config appsettings set -n {} -g {} '
'--settings KEY=VALUE'.format(site_name, rg))
_out(' Update flagged config: az webapp config set -n {} -g {} '
'--settings KEY=VALUE'.format(site_name, rg))
_out(' Check application logs: az webapp log tail -n {} -g {}'.format(site_name, rg))
26 changes: 26 additions & 0 deletions src/azure-cli/azure/cli/command_modules/appservice/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,28 @@ def transform_runtime_list_output(result):
]) for r in result]


def transform_troubleshoot_config_output(result):

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The table output will only show the config checks:
Image

JSON
image

"""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 []
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', s.get('Details') or s.get('details') or ''),
]) for s in settings if isinstance(s, dict)]



def ex_handler_factory(creating_plan=False):
def _ex_handler(ex):
ex = _polish_bad_errors(ex, creating_plan)
Expand Down Expand Up @@ -259,6 +281,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('config', 'troubleshoot_config',
table_transformer=transform_troubleshoot_config_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')
Expand Down
Loading