Skip to content
Merged
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
@@ -0,0 +1,233 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

"""
Context-enriched error builder for az webapp deploy / az webapp up.
Enabled via the --enriched-errors flag on az webapp deploy / az webapp up.
"""
Comment thread
Shi1810 marked this conversation as resolved.

import re

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

from ._deployment_failure_patterns import match_failure_pattern

logger = get_logger(__name__)


class EnrichedDeploymentError(CLIError):
# A CLIError subclass for context-enriched deployment failures.
pass


_STATUS_CODE_PATTERNS = [
re.compile(r'Status\s*Code[:\s]+(\d{3})', re.IGNORECASE), # "Status Code: 400"
re.compile(r'\(([45]\d{2})\)'), # "Bad Request(400)"
re.compile(r'HTTP\s+(\d{3})', re.IGNORECASE), # "HTTP 504"
re.compile(
r'\b([45]\d{2})\s+(?:Bad|Unauthorized|Forbidden|Not\s+Found|Conflict'
r'|Too\s+Many|Internal|Gateway|Service)', re.IGNORECASE), # "400 Bad Request"
]


def extract_status_code_from_message(message):
if not message:
return None
for pattern in _STATUS_CODE_PATTERNS:
m = pattern.search(message)
if m:
code = int(m.group(1))
if 400 <= code <= 599:
return code
return None


def _get_app_runtime(cmd, resource_group_name, webapp_name, slot=None):
try:
from ._client_factory import web_client_factory
client = web_client_factory(cmd.cli_ctx)
if slot:
config = client.web_apps.get_configuration_slot(resource_group_name, webapp_name, slot)
else:
config = client.web_apps.get_configuration(resource_group_name, webapp_name)
if config.linux_fx_version:
return config.linux_fx_version
return "Unknown"
except Exception: # pylint: disable=broad-except
return "Unknown"


def _get_app_region_and_plan_sku(cmd, resource_group_name, webapp_name):
try:
from ._client_factory import web_client_factory
from azure.mgmt.core.tools import parse_resource_id
client = web_client_factory(cmd.cli_ctx)
app = client.web_apps.get(resource_group_name, webapp_name)
region = app.location if app else "Unknown"
sku = "Unknown"
if app and app.server_farm_id:
plan_parts = parse_resource_id(app.server_farm_id)
plan = client.app_service_plans.get(plan_parts['resource_group'], plan_parts['name'])
if plan and plan.sku:
sku = plan.sku.name
return region, sku
except Exception: # pylint: disable=broad-except
return "Unknown", "Unknown"


_ARTIFACT_TYPE_MAP = {
'zip': 'ZipDeploy', 'war': 'WarDeploy', 'jar': 'JarDeploy',
'ear': 'EarDeploy', 'startup': 'StartupFile', 'static': 'StaticDeploy'
}


def _determine_deployment_type(params=None, *, src_url=None, artifact_type=None):
_src_url = src_url if src_url is not None else (getattr(params, 'src_url', None) if params else None)
_artifact = artifact_type if artifact_type is not None else (
getattr(params, 'artifact_type', None) if params else None)

if _src_url:
return "OneDeploy (URL-based)"
Comment thread
Shi1810 marked this conversation as resolved.

return _ARTIFACT_TYPE_MAP.get(_artifact, "OneDeploy")


def build_enriched_error_context(params=None, *, cmd=None, resource_group_name=None, # pylint: disable=too-many-locals
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):
_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)
_slot = slot if slot is not None else (
getattr(params, 'slot', None) if params else None)
_src_url = src_url if src_url is not None else (
getattr(params, 'src_url', None) if params else None)
_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,
)

# Build base context
context = {}

if pattern:
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"

# App metadata (best-effort)
if _cmd and _rg and _name:
context["runtime"] = _get_app_runtime(_cmd, _rg, _name, _slot)
region, plan_sku = _get_app_region_and_plan_sku(_cmd, _rg, _name)
context["region"] = region
context["planSku"] = plan_sku
else:
context["runtime"] = "Unknown"
context["region"] = "Unknown"
context["planSku"] = "Unknown"

context["deploymentType"] = _determine_deployment_type(
params, src_url=_src_url, artifact_type=_artifact
)

# Suggested fixes
if pattern:
context["suggestedFixes"] = pattern["suggestedFixes"]
else:
context["suggestedFixes"] = [
"Check deployment logs: 'az webapp log deployment show -n {} -g {}'".format(
_name or '<app>', _rg or '<rg>'),
"Check runtime logs: 'az webapp log tail -n {} -g {}'".format(
_name or '<app>', _rg or '<rg>')
]

# Extra diagnostics
if last_known_step:
context["lastKnownStep"] = last_known_step
if kudu_status:
context["kuduStatus"] = str(kudu_status)

# Raw details
if error_message:
if len(error_message) > 500:
context["rawError"] = error_message[:500] + "... [truncated]"
else:
context["rawError"] = error_message

return context


def format_enriched_error_message(context):
lines = []
lines.append("")
lines.append("=" * 72)
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')}")
lines.append(f"Region : {context.get('region', 'Unknown')}")
lines.append(f"Plan SKU : {context.get('planSku', 'Unknown')}")
if context.get("lastKnownStep"):
lines.append(f"Last Step : {context['lastKnownStep']}")
if context.get("kuduStatus"):
lines.append(f"Kudu Status : {context['kuduStatus']}")
lines.append("")

if context.get("rawError"):
lines.append(f"Raw Error : {context['rawError']}")
lines.append("")

fixes = context.get("suggestedFixes", [])
if fixes:
lines.append("Suggested Fixes:")
for f in fixes:
lines.append(f" - {f}")
lines.append("")

# Copilot prompt
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("-" * 72)

return "\n".join(lines)


def raise_enriched_deployment_error(params=None, *, cmd=None, resource_group_name=None,
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):
context = build_enriched_error_context(
params=params,
cmd=cmd,
resource_group_name=resource_group_name,
webapp_name=webapp_name,
slot=slot,
src_url=src_url,
artifact_type=artifact_type,
status_code=status_code,
error_message=error_message,
deployment_status=deployment_status,
last_known_step=last_known_step,
kudu_status=kudu_status
)

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

message = format_enriched_error_message(context)
Comment thread
Shi1810 marked this conversation as resolved.
raise EnrichedDeploymentError(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

DEPLOYMENT_FAILURE_PATTERNS = [
# 400 Bad Request — OneDeploy / general request validation
{
"errorCode": "DeploymentFailed",
Comment thread
Shi1810 marked this conversation as resolved.
"stage": "Deployment",
"httpStatus": 400,
"suggestedFixes": [
"Check the deployment request body and packageUri for correctness",
"Verify the artifact is a valid deployment package",
"Check deployment logs: 'az webapp log deployment show'"
]
},
{
"errorCode": "InvalidArtifactType",
"stage": "Deployment",
"httpStatus": 400,
"suggestedFixes": [
"Use a supported artifact type: zip, war, jar, ear, lib, startup, static, script",
"Check the 'type' query parameter in the deploy request"
]
},
{
"errorCode": "ArtifactStackMismatch",
"stage": "Deployment",
"httpStatus": 400,
"suggestedFixes": [
"Ensure the artifact type matches the app's runtime stack (e.g., war requires Tomcat)",
"Check 'az webapp config show' for the current linuxFxVersion",
"Update the runtime stack via 'az webapp config set --linux-fx-version'"
]
},
{
"errorCode": "MissingDeployPath",
"stage": "Deployment",
"httpStatus": 400,
"suggestedFixes": [
"Provide the 'path' query parameter for type=lib, type=script, or type=static",
"Review the OneDeploy API documentation for required parameters"
]
},
{
"errorCode": "InvalidDeployPath",
"stage": "Deployment",
"httpStatus": 400,
"suggestedFixes": [
"Remove trailing '/' from the deploy path",
"Use an absolute path; do not include '..' path segments",
"Review the deploy path for correct format"
]
},
{
"errorCode": "InvalidPackageUri",
"stage": "Deployment",
"httpStatus": 400,
"suggestedFixes": [
"Verify the packageUri is a valid, accessible URL",
"Ensure the packageUri is not empty or null in the JSON request body",
"Test the package URL is reachable from your network"
]
},
{
"errorCode": "CleanDeployForbidden",
"stage": "Deployment",
"httpStatus": 400,
"suggestedFixes": [
"Do not use clean=true when deploying to /home or /home/site",
"Change the deploy path to a subdirectory (e.g., /home/site/wwwroot)",
"Remove the 'clean=true' parameter from the deploy request"
]
},
{
"errorCode": "UnsupportedArtifactType",
"stage": "Deployment",
"httpStatus": 400,
"suggestedFixes": [
"Use a supported artifact type: zip, war, jar, ear, lib, startup, static, script",
"Check 'az webapp deploy --help' for valid type values"
]
},
# 409 Conflict
{
"errorCode": "DeploymentInProgress",
"stage": "Deployment",
"httpStatus": 409,
"suggestedFixes": [
"Wait for the current deployment to complete before starting a new one",
"Check deployment status: 'az webapp deployment show'",
"If stuck, restart the SCM site to release the deployment lock"
]
},
{
"errorCode": "RunFromRemoteZipConfigured",
"stage": "Deployment",
"httpStatus": 409,
"suggestedFixes": [
"Remove WEBSITE_RUN_FROM_PACKAGE (or legacy WEBSITE_RUN_FROM_ZIP) app setting pointing to a remote URL",
"Use 'az webapp config appsettings delete --setting-names WEBSITE_RUN_FROM_PACKAGE'",
"Set WEBSITE_RUN_FROM_PACKAGE to 1 instead of a URL"
]
},
]

# Index for O(1) lookup by error code
_PATTERN_INDEX = {p["errorCode"]: p for p in DEPLOYMENT_FAILURE_PATTERNS}


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


def match_failure_pattern(status_code=None, error_message=None): # pylint: disable=too-many-return-statements,too-many-branches
if error_message is None:
error_message = ""

error_lower = error_message.lower()

if status_code == 400:
if "not recognized" in error_lower and "type=" in error_lower:
return get_failure_pattern("InvalidArtifactType")
if "cannot be deployed to stack" in error_lower:
return get_failure_pattern("ArtifactStackMismatch")
if "artifact type" in error_lower and "not supported" in error_lower:
return get_failure_pattern("UnsupportedArtifactType")
if "path must be defined" in error_lower:
return get_failure_pattern("MissingDeployPath")
if "path cannot end with" in error_lower or "path cannot contain" in error_lower:
return get_failure_pattern("InvalidDeployPath")
if "invalid packageurl" in error_lower:
return get_failure_pattern("InvalidPackageUri")
if "clean deployments cannot be performed" in error_lower:
return get_failure_pattern("CleanDeployForbidden")
# Generic 400 - deployment failed pattern
return get_failure_pattern("DeploymentFailed")
if status_code == 409:
if ("run-from-zip" in error_lower or
"website_run_from_package" in error_lower or
"website_use_zip" in error_lower):
return get_failure_pattern("RunFromRemoteZipConfigured")
# Generic 409 - deployment lock conflict
return get_failure_pattern("DeploymentInProgress")
return None
5 changes: 5 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 @@ -2652,6 +2652,9 @@
- name: Create a web app with a specified domain name scope for unique hostname generation
text: >
az webapp up -n MyUniqueAppName --domain-name-scope TenantReuse
- name: Deploy with enriched error diagnostics on failure.
text: >
az webapp up --enriched-errors true
"""

helps['webapp update'] = """
Expand Down Expand Up @@ -3344,4 +3347,6 @@
text: az webapp deploy --resource-group ResourceGroup --name AppName --src-path SourcePath --type war --async true
- name: Deploy a static text file to wwwroot/staticfiles/test.txt
text: az webapp deploy --resource-group ResourceGroup --name AppName --src-path SourcePath --type static --target-path staticfiles/test.txt
- name: Deploy a zip file with enriched error diagnostics on failure.
text: az webapp deploy -g ResourceGroup -n AppName --src-path app.zip --enriched-errors true
"""
Loading
Loading