diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_deployment_context_engine.py b/src/azure-cli/azure/cli/command_modules/appservice/_deployment_context_engine.py index 4a78d29f7b6..72b53526ab2 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_deployment_context_engine.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_deployment_context_engine.py @@ -4,8 +4,8 @@ # -------------------------------------------------------------------------------------------- """ -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 @@ -13,7 +13,7 @@ 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__) @@ -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) @@ -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 = {} @@ -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: @@ -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 -g {}'".format(_rg or ''), + "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( @@ -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: @@ -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"): @@ -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) @@ -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, @@ -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) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_deployment_failure_patterns.py b/src/azure-cli/azure/cli/command_modules/appservice/_deployment_failure_patterns.py index b2fff32843e..d9eaa064b62 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_deployment_failure_patterns.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_deployment_failure_patterns.py @@ -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 '", + "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 --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 -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 @@ -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 \ No newline at end of file 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..06151e71874 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -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') 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..91666def565 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -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, @@ -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( @@ -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) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands.py index 9e99bef8136..ecd15f19eaf 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands.py @@ -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): @@ -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()