Skip to content

Commit 5aa864e

Browse files
authored
[App Service] az webapp deploy, az webapp up: Add enriched deployment failure logs for quicker resolution (#32940)
1 parent 9b7c484 commit 5aa864e

10 files changed

Lines changed: 6981 additions & 19 deletions
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
"""
7+
Context-enriched error builder for az webapp deploy / az webapp up.
8+
Enabled via the --enriched-errors flag on az webapp deploy / az webapp up.
9+
"""
10+
11+
import re
12+
13+
from knack.log import get_logger
14+
from knack.util import CLIError
15+
16+
from ._deployment_failure_patterns import match_failure_pattern
17+
18+
logger = get_logger(__name__)
19+
20+
21+
class EnrichedDeploymentError(CLIError):
22+
# A CLIError subclass for context-enriched deployment failures.
23+
pass
24+
25+
26+
_STATUS_CODE_PATTERNS = [
27+
re.compile(r'Status\s*Code[:\s]+(\d{3})', re.IGNORECASE), # "Status Code: 400"
28+
re.compile(r'\(([45]\d{2})\)'), # "Bad Request(400)"
29+
re.compile(r'HTTP\s+(\d{3})', re.IGNORECASE), # "HTTP 504"
30+
re.compile(
31+
r'\b([45]\d{2})\s+(?:Bad|Unauthorized|Forbidden|Not\s+Found|Conflict'
32+
r'|Too\s+Many|Internal|Gateway|Service)', re.IGNORECASE), # "400 Bad Request"
33+
]
34+
35+
36+
def extract_status_code_from_message(message):
37+
if not message:
38+
return None
39+
for pattern in _STATUS_CODE_PATTERNS:
40+
m = pattern.search(message)
41+
if m:
42+
code = int(m.group(1))
43+
if 400 <= code <= 599:
44+
return code
45+
return None
46+
47+
48+
def _get_app_runtime(cmd, resource_group_name, webapp_name, slot=None):
49+
try:
50+
from ._client_factory import web_client_factory
51+
client = web_client_factory(cmd.cli_ctx)
52+
if slot:
53+
config = client.web_apps.get_configuration_slot(resource_group_name, webapp_name, slot)
54+
else:
55+
config = client.web_apps.get_configuration(resource_group_name, webapp_name)
56+
if config.linux_fx_version:
57+
return config.linux_fx_version
58+
return "Unknown"
59+
except Exception: # pylint: disable=broad-except
60+
return "Unknown"
61+
62+
63+
def _get_app_region_and_plan_sku(cmd, resource_group_name, webapp_name):
64+
try:
65+
from ._client_factory import web_client_factory
66+
from azure.mgmt.core.tools import parse_resource_id
67+
client = web_client_factory(cmd.cli_ctx)
68+
app = client.web_apps.get(resource_group_name, webapp_name)
69+
region = app.location if app else "Unknown"
70+
sku = "Unknown"
71+
if app and app.server_farm_id:
72+
plan_parts = parse_resource_id(app.server_farm_id)
73+
plan = client.app_service_plans.get(plan_parts['resource_group'], plan_parts['name'])
74+
if plan and plan.sku:
75+
sku = plan.sku.name
76+
return region, sku
77+
except Exception: # pylint: disable=broad-except
78+
return "Unknown", "Unknown"
79+
80+
81+
_ARTIFACT_TYPE_MAP = {
82+
'zip': 'ZipDeploy', 'war': 'WarDeploy', 'jar': 'JarDeploy',
83+
'ear': 'EarDeploy', 'startup': 'StartupFile', 'static': 'StaticDeploy'
84+
}
85+
86+
87+
def _determine_deployment_type(params=None, *, src_url=None, artifact_type=None):
88+
_src_url = src_url if src_url is not None else (getattr(params, 'src_url', None) if params else None)
89+
_artifact = artifact_type if artifact_type is not None else (
90+
getattr(params, 'artifact_type', None) if params else None)
91+
92+
if _src_url:
93+
return "OneDeploy (URL-based)"
94+
95+
return _ARTIFACT_TYPE_MAP.get(_artifact, "OneDeploy")
96+
97+
98+
def build_enriched_error_context(params=None, *, cmd=None, resource_group_name=None, # pylint: disable=too-many-locals
99+
webapp_name=None, slot=None, src_url=None,
100+
artifact_type=None, status_code=None, error_message=None,
101+
deployment_status=None,
102+
last_known_step=None, kudu_status=None):
103+
_cmd = cmd or (params.cmd if params else None)
104+
_rg = resource_group_name or (params.resource_group_name if params else None)
105+
_name = webapp_name or (params.webapp_name if params else None)
106+
_slot = slot if slot is not None else (
107+
getattr(params, 'slot', None) if params else None)
108+
_src_url = src_url if src_url is not None else (
109+
getattr(params, 'src_url', None) if params else None)
110+
_artifact = artifact_type if artifact_type is not None else (
111+
getattr(params, 'artifact_type', None) if params else None)
112+
113+
pattern = match_failure_pattern(
114+
status_code=status_code,
115+
error_message=error_message,
116+
)
117+
118+
# Build base context
119+
context = {}
120+
121+
if pattern:
122+
context["errorCode"] = pattern["errorCode"]
123+
context["stage"] = pattern["stage"]
124+
else:
125+
context["errorCode"] = f"HTTP_{status_code}" if status_code else "UnknownDeploymentError"
126+
context["stage"] = deployment_status or "Unknown"
127+
128+
# App metadata (best-effort)
129+
if _cmd and _rg and _name:
130+
context["runtime"] = _get_app_runtime(_cmd, _rg, _name, _slot)
131+
region, plan_sku = _get_app_region_and_plan_sku(_cmd, _rg, _name)
132+
context["region"] = region
133+
context["planSku"] = plan_sku
134+
else:
135+
context["runtime"] = "Unknown"
136+
context["region"] = "Unknown"
137+
context["planSku"] = "Unknown"
138+
139+
context["deploymentType"] = _determine_deployment_type(
140+
params, src_url=_src_url, artifact_type=_artifact
141+
)
142+
143+
# Suggested fixes
144+
if pattern:
145+
context["suggestedFixes"] = pattern["suggestedFixes"]
146+
else:
147+
context["suggestedFixes"] = [
148+
"Check deployment logs: 'az webapp log deployment show -n {} -g {}'".format(
149+
_name or '<app>', _rg or '<rg>'),
150+
"Check runtime logs: 'az webapp log tail -n {} -g {}'".format(
151+
_name or '<app>', _rg or '<rg>')
152+
]
153+
154+
# Extra diagnostics
155+
if last_known_step:
156+
context["lastKnownStep"] = last_known_step
157+
if kudu_status:
158+
context["kuduStatus"] = str(kudu_status)
159+
160+
# Raw details
161+
if error_message:
162+
if len(error_message) > 500:
163+
context["rawError"] = error_message[:500] + "... [truncated]"
164+
else:
165+
context["rawError"] = error_message
166+
167+
return context
168+
169+
170+
def format_enriched_error_message(context):
171+
lines = []
172+
lines.append("")
173+
lines.append("=" * 72)
174+
lines.append("DEPLOYMENT FAILED: Context-Enriched Diagnostics")
175+
lines.append("=" * 72)
176+
lines.append("")
177+
178+
lines.append(f"Error Code : {context.get('errorCode', 'Unknown')}")
179+
lines.append(f"Stage : {context.get('stage', 'Unknown')}")
180+
lines.append(f"Runtime : {context.get('runtime', 'Unknown')}")
181+
lines.append(f"Deploy Type : {context.get('deploymentType', 'Unknown')}")
182+
lines.append(f"Region : {context.get('region', 'Unknown')}")
183+
lines.append(f"Plan SKU : {context.get('planSku', 'Unknown')}")
184+
if context.get("lastKnownStep"):
185+
lines.append(f"Last Step : {context['lastKnownStep']}")
186+
if context.get("kuduStatus"):
187+
lines.append(f"Kudu Status : {context['kuduStatus']}")
188+
lines.append("")
189+
190+
if context.get("rawError"):
191+
lines.append(f"Raw Error : {context['rawError']}")
192+
lines.append("")
193+
194+
fixes = context.get("suggestedFixes", [])
195+
if fixes:
196+
lines.append("Suggested Fixes:")
197+
for f in fixes:
198+
lines.append(f" - {f}")
199+
lines.append("")
200+
201+
# Copilot prompt
202+
lines.append("-" * 72)
203+
lines.append(" Copy the full error output above and paste it into GitHub Copilot Chat")
204+
lines.append(" with the prompt: 'Why did my Linux App Service deployment fail and how do I fix it?'")
205+
lines.append("-" * 72)
206+
207+
return "\n".join(lines)
208+
209+
210+
def raise_enriched_deployment_error(params=None, *, cmd=None, resource_group_name=None,
211+
webapp_name=None, slot=None, src_url=None,
212+
artifact_type=None, status_code=None, error_message=None,
213+
deployment_status=None,
214+
last_known_step=None, kudu_status=None):
215+
context = build_enriched_error_context(
216+
params=params,
217+
cmd=cmd,
218+
resource_group_name=resource_group_name,
219+
webapp_name=webapp_name,
220+
slot=slot,
221+
src_url=src_url,
222+
artifact_type=artifact_type,
223+
status_code=status_code,
224+
error_message=error_message,
225+
deployment_status=deployment_status,
226+
last_known_step=last_known_step,
227+
kudu_status=kudu_status
228+
)
229+
230+
logger.debug("Deployment failure context: %s", context)
231+
232+
message = format_enriched_error_message(context)
233+
raise EnrichedDeploymentError(message)
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
DEPLOYMENT_FAILURE_PATTERNS = [
7+
# 400 Bad Request — OneDeploy / general request validation
8+
{
9+
"errorCode": "DeploymentFailed",
10+
"stage": "Deployment",
11+
"httpStatus": 400,
12+
"suggestedFixes": [
13+
"Check the deployment request body and packageUri for correctness",
14+
"Verify the artifact is a valid deployment package",
15+
"Check deployment logs: 'az webapp log deployment show'"
16+
]
17+
},
18+
{
19+
"errorCode": "InvalidArtifactType",
20+
"stage": "Deployment",
21+
"httpStatus": 400,
22+
"suggestedFixes": [
23+
"Use a supported artifact type: zip, war, jar, ear, lib, startup, static, script",
24+
"Check the 'type' query parameter in the deploy request"
25+
]
26+
},
27+
{
28+
"errorCode": "ArtifactStackMismatch",
29+
"stage": "Deployment",
30+
"httpStatus": 400,
31+
"suggestedFixes": [
32+
"Ensure the artifact type matches the app's runtime stack (e.g., war requires Tomcat)",
33+
"Check 'az webapp config show' for the current linuxFxVersion",
34+
"Update the runtime stack via 'az webapp config set --linux-fx-version'"
35+
]
36+
},
37+
{
38+
"errorCode": "MissingDeployPath",
39+
"stage": "Deployment",
40+
"httpStatus": 400,
41+
"suggestedFixes": [
42+
"Provide the 'path' query parameter for type=lib, type=script, or type=static",
43+
"Review the OneDeploy API documentation for required parameters"
44+
]
45+
},
46+
{
47+
"errorCode": "InvalidDeployPath",
48+
"stage": "Deployment",
49+
"httpStatus": 400,
50+
"suggestedFixes": [
51+
"Remove trailing '/' from the deploy path",
52+
"Use an absolute path; do not include '..' path segments",
53+
"Review the deploy path for correct format"
54+
]
55+
},
56+
{
57+
"errorCode": "InvalidPackageUri",
58+
"stage": "Deployment",
59+
"httpStatus": 400,
60+
"suggestedFixes": [
61+
"Verify the packageUri is a valid, accessible URL",
62+
"Ensure the packageUri is not empty or null in the JSON request body",
63+
"Test the package URL is reachable from your network"
64+
]
65+
},
66+
{
67+
"errorCode": "CleanDeployForbidden",
68+
"stage": "Deployment",
69+
"httpStatus": 400,
70+
"suggestedFixes": [
71+
"Do not use clean=true when deploying to /home or /home/site",
72+
"Change the deploy path to a subdirectory (e.g., /home/site/wwwroot)",
73+
"Remove the 'clean=true' parameter from the deploy request"
74+
]
75+
},
76+
{
77+
"errorCode": "UnsupportedArtifactType",
78+
"stage": "Deployment",
79+
"httpStatus": 400,
80+
"suggestedFixes": [
81+
"Use a supported artifact type: zip, war, jar, ear, lib, startup, static, script",
82+
"Check 'az webapp deploy --help' for valid type values"
83+
]
84+
},
85+
# 409 Conflict
86+
{
87+
"errorCode": "DeploymentInProgress",
88+
"stage": "Deployment",
89+
"httpStatus": 409,
90+
"suggestedFixes": [
91+
"Wait for the current deployment to complete before starting a new one",
92+
"Check deployment status: 'az webapp deployment show'",
93+
"If stuck, restart the SCM site to release the deployment lock"
94+
]
95+
},
96+
{
97+
"errorCode": "RunFromRemoteZipConfigured",
98+
"stage": "Deployment",
99+
"httpStatus": 409,
100+
"suggestedFixes": [
101+
"Remove WEBSITE_RUN_FROM_PACKAGE (or legacy WEBSITE_RUN_FROM_ZIP) app setting pointing to a remote URL",
102+
"Use 'az webapp config appsettings delete --setting-names WEBSITE_RUN_FROM_PACKAGE'",
103+
"Set WEBSITE_RUN_FROM_PACKAGE to 1 instead of a URL"
104+
]
105+
},
106+
]
107+
108+
# Index for O(1) lookup by error code
109+
_PATTERN_INDEX = {p["errorCode"]: p for p in DEPLOYMENT_FAILURE_PATTERNS}
110+
111+
112+
def get_failure_pattern(error_code):
113+
return _PATTERN_INDEX.get(error_code)
114+
115+
116+
def match_failure_pattern(status_code=None, error_message=None): # pylint: disable=too-many-return-statements,too-many-branches
117+
if error_message is None:
118+
error_message = ""
119+
120+
error_lower = error_message.lower()
121+
122+
if status_code == 400:
123+
if "not recognized" in error_lower and "type=" in error_lower:
124+
return get_failure_pattern("InvalidArtifactType")
125+
if "cannot be deployed to stack" in error_lower:
126+
return get_failure_pattern("ArtifactStackMismatch")
127+
if "artifact type" in error_lower and "not supported" in error_lower:
128+
return get_failure_pattern("UnsupportedArtifactType")
129+
if "path must be defined" in error_lower:
130+
return get_failure_pattern("MissingDeployPath")
131+
if "path cannot end with" in error_lower or "path cannot contain" in error_lower:
132+
return get_failure_pattern("InvalidDeployPath")
133+
if "invalid packageurl" in error_lower:
134+
return get_failure_pattern("InvalidPackageUri")
135+
if "clean deployments cannot be performed" in error_lower:
136+
return get_failure_pattern("CleanDeployForbidden")
137+
# Generic 400 - deployment failed pattern
138+
return get_failure_pattern("DeploymentFailed")
139+
if status_code == 409:
140+
if ("run-from-zip" in error_lower or
141+
"website_run_from_package" in error_lower or
142+
"website_use_zip" in error_lower):
143+
return get_failure_pattern("RunFromRemoteZipConfigured")
144+
# Generic 409 - deployment lock conflict
145+
return get_failure_pattern("DeploymentInProgress")
146+
return None

src/azure-cli/azure/cli/command_modules/appservice/_help.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2656,6 +2656,9 @@
26562656
- name: Create a web app with a specified domain name scope for unique hostname generation
26572657
text: >
26582658
az webapp up -n MyUniqueAppName --domain-name-scope TenantReuse
2659+
- name: Deploy with enriched error diagnostics on failure.
2660+
text: >
2661+
az webapp up --enriched-errors true
26592662
"""
26602663

26612664
helps['webapp update'] = """
@@ -3348,4 +3351,6 @@
33483351
text: az webapp deploy --resource-group ResourceGroup --name AppName --src-path SourcePath --type war --async true
33493352
- name: Deploy a static text file to wwwroot/staticfiles/test.txt
33503353
text: az webapp deploy --resource-group ResourceGroup --name AppName --src-path SourcePath --type static --target-path staticfiles/test.txt
3354+
- name: Deploy a zip file with enriched error diagnostics on failure.
3355+
text: az webapp deploy -g ResourceGroup -n AppName --src-path app.zip --enriched-errors true
33513356
"""

0 commit comments

Comments
 (0)