Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@
# --------------------------------------------------------------------------------------------

"""
Context-enriched error builder for az webapp deploy / az webapp up.
Enabled via the --enriched-errors flag on az webapp deploy / az webapp up.
Context-enriched error builder for az webapp deploy / up / create.
Enabled via the --enriched-errors flag on those commands.
"""

import re

from knack.log import get_logger
from knack.util import CLIError

from ._deployment_failure_patterns import match_failure_pattern
from ._deployment_failure_patterns import match_failure_pattern, match_create_failure_pattern

logger = get_logger(__name__)

Expand Down Expand Up @@ -101,7 +101,7 @@ def build_enriched_error_context(params=None, *, cmd=None, resource_group_name=N
webapp_name=None, slot=None, src_url=None,
artifact_type=None, status_code=None, error_message=None,
deployment_status=None,
last_known_step=None, kudu_status=None):
last_known_step=None, kudu_status=None, operation='deploy'):
_cmd = cmd or (params.cmd if params else None)
_rg = resource_group_name or (params.resource_group_name if params else None)
_name = webapp_name or (params.webapp_name if params else None)
Expand All @@ -112,10 +112,17 @@ def build_enriched_error_context(params=None, *, cmd=None, resource_group_name=N
_artifact = artifact_type if artifact_type is not None else (
getattr(params, 'artifact_type', None) if params else None)

pattern = match_failure_pattern(
status_code=status_code,
error_message=error_message,
)
is_create = operation == 'create'
if is_create:
pattern = match_create_failure_pattern(
status_code=status_code,
error_message=error_message,
)
else:
pattern = match_failure_pattern(
status_code=status_code,
error_message=error_message,
)

# Build base context
context = {}
Expand All @@ -124,8 +131,9 @@ def build_enriched_error_context(params=None, *, cmd=None, resource_group_name=N
context["errorCode"] = pattern["errorCode"]
context["stage"] = pattern["stage"]
else:
context["errorCode"] = f"HTTP_{status_code}" if status_code else "UnknownDeploymentError"
context["stage"] = deployment_status or "Unknown"
default_code = "WebAppCreateError" if is_create else "UnknownDeploymentError"
context["errorCode"] = f"HTTP_{status_code}" if status_code else default_code
context["stage"] = deployment_status or ("WebAppCreate" if is_create else "Unknown")

# App metadata (best-effort)
if _cmd and _rg and _name:
Expand All @@ -145,6 +153,12 @@ def build_enriched_error_context(params=None, *, cmd=None, resource_group_name=N
# Suggested fixes
if pattern:
context["suggestedFixes"] = pattern["suggestedFixes"]
elif is_create:
context["suggestedFixes"] = [
"Verify the plan exists and is Linux: 'az appservice plan show -n <plan> -g {}'".format(_rg or '<rg>'),
"Confirm the region/SKU supports your runtime: 'az appservice list-locations --linux-workers-enabled'",
"Use a globally unique --name and retry 'az webapp create'"
]
else:
context["suggestedFixes"] = [
"Check deployment logs: 'az webapp log deployment show -n {} -g {}'".format(
Expand All @@ -159,6 +173,8 @@ def build_enriched_error_context(params=None, *, cmd=None, resource_group_name=N
if kudu_status:
context["kuduStatus"] = str(kudu_status)

context["operation"] = operation

# Raw details
if error_message:
if len(error_message) > 500:
Expand All @@ -170,17 +186,22 @@ def build_enriched_error_context(params=None, *, cmd=None, resource_group_name=N


def format_enriched_error_message(context):
is_create = context.get("operation") == "create"
lines = []
lines.append("")
lines.append("=" * 72)
lines.append("DEPLOYMENT FAILED: Context-Enriched Diagnostics")
if is_create:
lines.append("WEB APP CREATION FAILED: Context-Enriched Diagnostics")
else:
lines.append("DEPLOYMENT FAILED: Context-Enriched Diagnostics")
lines.append("=" * 72)
lines.append("")

lines.append(f"Error Code : {context.get('errorCode', 'Unknown')}")
lines.append(f"Stage : {context.get('stage', 'Unknown')}")
lines.append(f"Runtime : {context.get('runtime', 'Unknown')}")
lines.append(f"Deploy Type : {context.get('deploymentType', 'Unknown')}")
if not is_create:
lines.append(f"Deploy Type : {context.get('deploymentType', 'Unknown')}")
lines.append(f"Region : {context.get('region', 'Unknown')}")
lines.append(f"Plan SKU : {context.get('planSku', 'Unknown')}")
if context.get("lastKnownStep"):
Expand All @@ -201,9 +222,12 @@ def format_enriched_error_message(context):
lines.append("")

# Copilot prompt
prompt = ("Why did my Linux App Service web app creation fail and how do I fix it?"
if is_create else
"Why did my Linux App Service deployment fail and how do I fix it?")
lines.append("-" * 72)
lines.append(" Copy the full error output above and paste it into GitHub Copilot Chat")
lines.append(" with the prompt: 'Why did my Linux App Service deployment fail and how do I fix it?'")
lines.append(f" with the prompt: '{prompt}'")
lines.append("-" * 72)

return "\n".join(lines)
Expand All @@ -213,7 +237,7 @@ def raise_enriched_deployment_error(params=None, *, cmd=None, resource_group_nam
webapp_name=None, slot=None, src_url=None,
artifact_type=None, status_code=None, error_message=None,
deployment_status=None,
last_known_step=None, kudu_status=None):
last_known_step=None, kudu_status=None, operation='deploy'):
context = build_enriched_error_context(
params=params,
cmd=cmd,
Expand All @@ -226,10 +250,11 @@ def raise_enriched_deployment_error(params=None, *, cmd=None, resource_group_nam
error_message=error_message,
deployment_status=deployment_status,
last_known_step=last_known_step,
kudu_status=kudu_status
kudu_status=kudu_status,
operation=operation
)

logger.debug("Deployment failure context: %s", context)
logger.debug("%s failure context: %s", operation, context)

message = format_enriched_error_message(context)
raise EnrichedDeploymentError(message)
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,66 @@
# Index for O(1) lookup by error code
_PATTERN_INDEX = {p["errorCode"]: p for p in DEPLOYMENT_FAILURE_PATTERNS}

# Failure patterns specific to creating a Linux web app (az webapp create). These
# describe ARM/control-plane errors surfaced while provisioning the Site, not Kudu
# deployment errors, so they have their own table and matcher.
WEBAPP_CREATE_FAILURE_PATTERNS = [
{
"errorCode": "SubscriptionNotRegistered",
"stage": "WebAppCreate",
"suggestedFixes": [
"Register the App Service provider: 'az provider register --namespace Microsoft.Web'",
"Confirm registration: 'az provider show -n Microsoft.Web --query registrationState'"
]
},
{
"errorCode": "QuotaExceeded",
"stage": "WebAppCreate",
"suggestedFixes": [
"Delete unused apps/plans in the region or pick a higher SKU/region",
"Check current usage: 'az appservice list-locations --sku <sku>'",
"Request a quota increase via an Azure support request"
]
},
{
"errorCode": "SkuNotAvailableInRegion",
"stage": "WebAppCreate",
"suggestedFixes": [
"Choose a region that supports the plan SKU: 'az appservice list-locations --sku <sku> --linux-workers-enabled'",
"Create the plan with a different SKU, then retry 'az webapp create'"
]
},
{
"errorCode": "ServerFarmNotFound",
"stage": "WebAppCreate",
"suggestedFixes": [
"Verify the plan exists: 'az appservice plan list -g <rg> -o table'",
"Pass an existing plan name or full resource ID via --plan"
]
},
{
"errorCode": "SiteNameUnavailable",
"stage": "WebAppCreate",
"suggestedFixes": [
"Choose a globally unique app name (the default *.azurewebsites.net host is shared)",
"Check availability via a different --name value"
]
},
{
"errorCode": "LinuxWorkersUnavailable",
"stage": "WebAppCreate",
"suggestedFixes": [
"Create a Linux plan in a region with Linux capacity: 'az appservice list-locations --linux-workers-enabled'",
"Recreate the plan with '--is-linux' in a supported region"
]
},
]

_CREATE_PATTERN_INDEX = {p["errorCode"]: p for p in WEBAPP_CREATE_FAILURE_PATTERNS}


def get_failure_pattern(error_code):
return _PATTERN_INDEX.get(error_code)
return _PATTERN_INDEX.get(error_code) or _CREATE_PATTERN_INDEX.get(error_code)


def match_failure_pattern(status_code=None, error_message=None): # pylint: disable=too-many-return-statements,too-many-branches
Expand Down Expand Up @@ -144,3 +201,22 @@ def match_failure_pattern(status_code=None, error_message=None): # pylint: disa
# Generic 409 - deployment lock conflict
return get_failure_pattern("DeploymentInProgress")
return None


def match_create_failure_pattern(status_code=None, error_message=None): # pylint: disable=too-many-return-statements
"""Map an az webapp create control-plane failure to a known pattern, or None."""
error_lower = (error_message or "").lower()

if "register" in error_lower and "microsoft.web" in error_lower:
return get_failure_pattern("SubscriptionNotRegistered")
if "quota" in error_lower or "exceeded" in error_lower or "over quota" in error_lower:
return get_failure_pattern("QuotaExceeded")
if "linux" in error_lower and ("worker" in error_lower or "not available" in error_lower):
return get_failure_pattern("LinuxWorkersUnavailable")
if "sku" in error_lower and ("not available" in error_lower or "not supported" in error_lower):
return get_failure_pattern("SkuNotAvailableInRegion")
if "serverfarm" in error_lower or "server farm" in error_lower or "app service plan" in error_lower:
return get_failure_pattern("ServerFarmNotFound")
if status_code == 409 or "conflict" in error_lower or "already exists" in error_lower:
return get_failure_pattern("SiteNameUnavailable")
return None
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,9 @@ def load_arguments(self, _):
help="The minimum version of TLS required for SSL requests, e.g., '1.0', '1.1', '1.2'")
c.argument('min_tls_cipher_suite', options_list=['--min-tls-cipher-suite'],
help="The minimum TLS Cipher Suite required for requests, e.g., 'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384'")
c.argument('enriched_errors', options_list=['--enriched-errors'],
help='If true, web app creation failures will show context-enriched diagnostics with error codes, suggested fixes, and Copilot prompts.',
arg_type=get_three_state_flag())
c.ignore('language')
c.ignore('using_webapp_up')

Expand Down
28 changes: 24 additions & 4 deletions src/azure-cli/azure/cli/command_modules/appservice/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
from ._appservice_utils import _generic_site_operation, _generic_settings_operation
from ._appservice_utils import MSI_LOCAL_ID
from ._deployment_context_engine import (
raise_enriched_deployment_error, EnrichedDeploymentError
raise_enriched_deployment_error, EnrichedDeploymentError, extract_status_code_from_message
)
from .utils import (_normalize_sku,
get_sku_tier,
Expand Down Expand Up @@ -130,7 +130,8 @@ def create_webapp(cmd, resource_group_name, name, plan, runtime=None, startup_fi
role='Contributor', scope=None, vnet=None, subnet=None, https_only=False,
public_network_access=None, acr_use_identity=False, acr_identity=None, basic_auth="",
auto_generated_domain_name_label_scope=None, end_to_end_encryption_enabled=None,
min_tls_version=None, min_tls_cipher_suite=None, site_scoped_certs=None):
min_tls_version=None, min_tls_cipher_suite=None, site_scoped_certs=None,
enriched_errors=False):
from azure.mgmt.web.models import Site, OutboundVnetRouting
from azure.core.exceptions import ResourceNotFoundError as _ResourceNotFoundError
SiteConfig, SkuDescription, NameValuePair = cmd.get_models(
Expand Down Expand Up @@ -357,8 +358,27 @@ def create_webapp(cmd, resource_group_name, name, plan, runtime=None, startup_fi
value='https://{}.scm.azurewebsites.net/detectors'
.format(name)))

poller = client.web_apps.begin_create_or_update(resource_group_name, name, webapp_def)
webapp = LongRunningOperation(cmd.cli_ctx)(poller)
# Only enrich Linux web app failures (parity with 'az webapp deploy'); container/Windows
# apps surface the raw service error unchanged.
_should_enrich_errors = enriched_errors and is_linux
try:
poller = client.web_apps.begin_create_or_update(resource_group_name, name, webapp_def)
webapp = LongRunningOperation(cmd.cli_ctx)(poller)
except EnrichedDeploymentError:
raise
except Exception as ex: # pylint: disable=broad-except
if _should_enrich_errors:
raise_enriched_deployment_error(
cmd=cmd,
resource_group_name=resource_group_name,
webapp_name=name,
status_code=extract_status_code_from_message(str(ex)),
error_message=str(ex),
deployment_status="WebAppCreate",
last_known_step="Site create_or_update request",
operation="create"
)
raise

if current_stack:
_update_webapp_current_stack_property_if_needed(cmd, resource_group_name, name, current_stack)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3631,6 +3631,18 @@ def test_webapp_deploy_without_enriched_errors(self, resource_group):
self.cmd('webapp deploy -g {} -n {} --src-path "{}" --type war'.format(
resource_group, webapp_name, war_file))

@AllowLargeResponse()
@ResourceGroupPreparer(name_prefix='cli_test_webapp_create_enriched', location='westus2')
def test_webapp_create_enriched_errors_skipped_for_windows(self, resource_group):
"""Windows web app failures should NOT be enriched even with --enriched-errors true."""
webapp_name = self.create_random_name('webapp-cr-win', 40)
plan_name = self.create_random_name('webapp-cr-win-plan', 40)
self.cmd('appservice plan create -g {} -n {}'.format(resource_group, plan_name))
with self.assertRaises(CLIError) as cm:
self.cmd('webapp create -g {} -n {} --plan {} -r "DOTNET|99.99" --enriched-errors true'.format(
resource_group, webapp_name, plan_name))
self.assertNotIn('WEB APP CREATION FAILED', str(cm.exception))

@AllowLargeResponse()
@ResourceGroupPreparer(name_prefix='cli_test_webapp_up_enriched', location='westus2')
def test_webapp_up_enriched_errors_flag_accepted(self, resource_group):
Expand Down Expand Up @@ -3660,5 +3672,41 @@ def test_webapp_up_enriched_errors_flag_accepted(self, resource_group):
os.chdir(original_dir)


class AppservicePlanCreateEnrichedErrorsScenarioTest(ScenarioTest):
"""Scenario tests for --enriched-errors flag on az appservice plan create."""

@AllowLargeResponse()
@ResourceGroupPreparer(name_prefix='cli_test_asp_enriched_errors', location='westus2')
def test_asp_create_enriched_errors_invalid_location(self, resource_group):
"""Create a Linux plan in an invalid location with --enriched-errors true — should get LocationNotAvailable."""
from azure.cli.command_modules.appservice._deployment_context_engine import EnrichedDeploymentError
plan_name = self.create_random_name('asp-enriched', 40)
with self.assertRaises(EnrichedDeploymentError) as cm:
self.cmd('appservice plan create -g {} -n {} --sku B1 --is-linux true '
'--location nowhereland --enriched-errors true'.format(resource_group, plan_name))
self.assertIn('APP SERVICE PLAN CREATION FAILED', str(cm.exception))
self.assertIn('LocationNotAvailable', str(cm.exception))

@AllowLargeResponse()
@ResourceGroupPreparer(name_prefix='cli_test_asp_enriched_errors', location='westus2')
def test_asp_create_without_enriched_errors(self, resource_group):
"""Create a plan in an invalid location without --enriched-errors — should get original CLIError."""
plan_name = self.create_random_name('asp-plain', 40)
with self.assertRaises(CLIError) as cm:
self.cmd('appservice plan create -g {} -n {} --sku B1 --is-linux true '
'--location nowhereland'.format(resource_group, plan_name))
self.assertNotIn('APP SERVICE PLAN CREATION FAILED', str(cm.exception))

@AllowLargeResponse()
@ResourceGroupPreparer(name_prefix='cli_test_asp_enriched_errors', location='westus2')
def test_asp_create_enriched_errors_skipped_for_windows(self, resource_group):
"""Windows plan (--is-linux false) failures should NOT be enriched even with --enriched-errors true."""
plan_name = self.create_random_name('asp-win', 40)
with self.assertRaises(CLIError) as cm:
self.cmd('appservice plan create -g {} -n {} --sku B1 --is-linux false '
'--location nowhereland --enriched-errors true'.format(resource_group, plan_name))
self.assertNotIn('APP SERVICE PLAN CREATION FAILED', str(cm.exception))


if __name__ == '__main__':
unittest.main()
Loading