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..e0e41bbbd27 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 @@ -233,3 +233,102 @@ def raise_enriched_deployment_error(params=None, *, cmd=None, resource_group_nam message = format_enriched_error_message(context) raise EnrichedDeploymentError(message) + + +def build_enriched_plan_error_context(*, resource_group_name=None, plan_name=None, + location=None, sku=None, status_code=None, + error_message=None, last_known_step=None): + from ._deployment_failure_patterns import match_control_plane_failure_pattern + + pattern = match_control_plane_failure_pattern( + status_code=status_code, + error_message=error_message, + ) + + context = {} + + if pattern: + context["errorCode"] = pattern["errorCode"] + context["stage"] = pattern["stage"] + context["suggestedFixes"] = pattern["suggestedFixes"] + else: + context["errorCode"] = f"HTTP_{status_code}" if status_code else "UnknownPlanCreateError" + context["stage"] = "ResourceProvisioning" + context["suggestedFixes"] = [ + "Review the raw error message below for the failing property", + "Verify --sku and --location are valid: 'az appservice list-locations --sku '", + "Confirm the resource group exists and you have Contributor access" + ] + + context["resourceGroup"] = resource_group_name or "Unknown" + context["planName"] = plan_name or "Unknown" + context["region"] = location or "Unknown" + context["planSku"] = sku or "Unknown" + + if last_known_step: + context["lastKnownStep"] = last_known_step + + if error_message: + if len(error_message) > 500: + context["rawError"] = error_message[:500] + "... [truncated]" + else: + context["rawError"] = error_message + + return context + + +def format_enriched_plan_error_message(context): + lines = [] + lines.append("") + lines.append("=" * 72) + lines.append("APP SERVICE PLAN CREATION 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"Plan Name : {context.get('planName', 'Unknown')}") + lines.append(f"Resource Grp: {context.get('resourceGroup', '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']}") + 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 az appservice plan create fail and how do I fix it?'") + lines.append("-" * 72) + + return "\n".join(lines) + + +def raise_enriched_plan_error(*, resource_group_name=None, plan_name=None, + location=None, sku=None, status_code=None, + error_message=None, last_known_step=None): + context = build_enriched_plan_error_context( + resource_group_name=resource_group_name, + plan_name=plan_name, + location=location, + sku=sku, + status_code=status_code, + error_message=error_message, + last_known_step=last_known_step, + ) + + logger.debug("App Service plan creation failure context: %s", context) + + message = format_enriched_plan_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..81feef3e664 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 @@ -109,8 +109,89 @@ _PATTERN_INDEX = {p["errorCode"]: p for p in DEPLOYMENT_FAILURE_PATTERNS} +# Control-plane (ARM) failure patterns for resource creation operations such as +# 'az appservice plan create'. These are management-plane errors (quota, SKU/region +# availability, authorization, registration) rather than Kudu deployment failures. +CONTROL_PLANE_FAILURE_PATTERNS = [ + { + "errorCode": "QuotaExceeded", + "stage": "ResourceProvisioning", + "httpStatus": 401, + "suggestedFixes": [ + "Your subscription has reached its App Service Plan worker quota for this SKU/region", + "Request an increase in the Azure portal: Subscription > Usage + quotas, " + "filter Provider = App Service, then New Quota Request", + "Or try a different region or a lower SKU/worker count" + ] + }, + { + "errorCode": "SkuNotAvailable", + "stage": "ResourceProvisioning", + "httpStatus": 400, + "suggestedFixes": [ + "The selected --sku is not available in the chosen region", + "List available SKUs/regions: 'az appservice list-locations --sku '", + "Choose a supported SKU or deploy to a different region" + ] + }, + { + "errorCode": "LocationNotAvailable", + "stage": "ResourceProvisioning", + "httpStatus": 400, + "suggestedFixes": [ + "The resource type is not available in the specified --location", + "List supported regions: 'az appservice list-locations'", + "Pick a region where App Service plans of this SKU are offered" + ] + }, + { + "errorCode": "AuthorizationFailed", + "stage": "Authorization", + "httpStatus": 403, + "suggestedFixes": [ + "Your account lacks permission to create the App Service plan in this scope", + "Ensure you have at least 'Contributor' on the resource group/subscription", + "Verify the active subscription: 'az account show'" + ] + }, + { + "errorCode": "ResourceGroupNotFound", + "stage": "ResourceProvisioning", + "httpStatus": 404, + "suggestedFixes": [ + "The target resource group does not exist", + "Create it first: 'az group create -n -l '", + "Check the --resource-group value and active subscription" + ] + }, + { + "errorCode": "MissingSubscriptionRegistration", + "stage": "ResourceProvisioning", + "httpStatus": 409, + "suggestedFixes": [ + "The Microsoft.Web resource provider is not registered for this subscription", + "Register it: 'az provider register --namespace Microsoft.Web'", + "Check status: 'az provider show --namespace Microsoft.Web --query registrationState'" + ] + }, + { + "errorCode": "ZoneRedundancyUnsupported", + "stage": "ResourceProvisioning", + "httpStatus": 400, + "suggestedFixes": [ + "Zone redundancy requires a supported SKU and a minimum of 3 workers", + "Use a Premium V2/V3 SKU and set --number-of-workers to 3 or more", + "Or remove --zone-redundant to create a non-zone-redundant plan" + ] + }, +] + +# Index for O(1) lookup by error code (deployment + control-plane) +_CONTROL_PLANE_PATTERN_INDEX = {p["errorCode"]: p for p in CONTROL_PLANE_FAILURE_PATTERNS} + + def get_failure_pattern(error_code): - return _PATTERN_INDEX.get(error_code) + return _PATTERN_INDEX.get(error_code) or _CONTROL_PLANE_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 +225,43 @@ 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_control_plane_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() + + # Message-based matching first (status codes are inconsistent across ARM errors) + if "quota" in error_lower and ("exceed" in error_lower or "insufficient" in error_lower or + "additional" in error_lower): + return get_failure_pattern("QuotaExceeded") + if "authorizationfailed" in error_lower or "does not have authorization" in error_lower or \ + "not authorized" in error_lower: + return get_failure_pattern("AuthorizationFailed") + if "missingsubscriptionregistration" in error_lower or "not registered to use namespace" in error_lower: + return get_failure_pattern("MissingSubscriptionRegistration") + if "resource group" in error_lower and ("could not be found" in error_lower or "not found" in error_lower): + return get_failure_pattern("ResourceGroupNotFound") + if ("sku" in error_lower or "tier" in error_lower) and ("not available" in error_lower or + "not supported" in error_lower or + "skunotavailable" in error_lower): + return get_failure_pattern("SkuNotAvailable") + if ("location" in error_lower or "region" in error_lower) and ("not available" in error_lower or + "not supported" in error_lower): + return get_failure_pattern("LocationNotAvailable") + if "zone" in error_lower and ("redundan" in error_lower): + return get_failure_pattern("ZoneRedundancyUnsupported") + + # Status-code fallbacks + if status_code == 401 or status_code == 403: + return get_failure_pattern("AuthorizationFailed") + if status_code == 404: + return get_failure_pattern("ResourceGroupNotFound") + if status_code == 409: + return get_failure_pattern("MissingSubscriptionRegistration") + # Note: no generic 400 fallback. A 400 that did not match a specific message + # pattern above is left unclassified (returns None) so the caller produces a + # generic HTTP_400 context rather than a potentially wrong SKU diagnosis. + return None 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..d799879e973 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -164,6 +164,9 @@ def load_arguments(self, _): help="Install script configurations. Provide key-value pairs for `name= source-uri= type=`.") c.argument('storage_mounts', options_list=['--storage-mount'], is_preview=True, action=StorageMountAddAction, nargs='+', help="Storage mount configurations. Provide key-value pairs for `name= source= type= destination-path= credentials-secret-uri=`.") + c.argument('enriched_errors', options_list=['--enriched-errors'], + help='If true, Linux App Service plan creation failures will show context-enriched diagnostics with error codes, suggested fixes, and Copilot prompts. This flag only applies to Linux plans and has no effect on Windows or Hyper-V plans.', + arg_type=get_three_state_flag()) with self.argument_context('appservice plan update') as c: c.argument('sku', arg_type=sku_arg_type, 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..91e63b0fad9 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,8 @@ 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, + raise_enriched_plan_error, extract_status_code_from_message ) from .utils import (_normalize_sku, get_sku_tier, @@ -5003,12 +5004,32 @@ def is_async_response(poller, timeout_seconds=30): return status_code == 202 +def _raise_enriched_plan_create_error(ex, resource_group_name, name, location, sku): + error_message = getattr(ex, 'message', None) or str(ex) + status_code = getattr(ex, 'status_code', None) + if status_code is None: + response = getattr(ex, 'response', None) + status_code = getattr(response, 'status_code', None) + if status_code is None: + status_code = extract_status_code_from_message(error_message) + raise_enriched_plan_error( + resource_group_name=resource_group_name, + plan_name=name, + location=location, + sku=sku, + status_code=status_code, + error_message=error_message, + last_known_step="App Service Plan create (control-plane request)" + ) + + def create_app_service_plan(cmd, resource_group_name, name, is_linux, hyper_v, per_site_scaling=False, # pylint: disable=too-many-branches app_service_environment=None, sku=None, number_of_workers=None, location=None, tags=None, no_wait=False, zone_redundant=False, async_scaling_enabled=None, is_managed_instance=None, mi_system_assigned=None, mi_user_assigned=None, default_identity=None, rdp_enabled=None, vnet=None, subnet=None, - registry_adapters=None, install_scripts=None, storage_mounts=None): + registry_adapters=None, install_scripts=None, storage_mounts=None, + enriched_errors=False): if is_linux is None: is_linux = not hyper_v elif is_linux and hyper_v: @@ -5106,44 +5127,51 @@ def pre_operations(self): os_type = 'Linux' if is_linux else ('Hyper-V' if hyper_v else 'Windows') logger.warning("Creating App Service Plan '%s' (%s, SKU: %s).", name, os_type, sku) - poller = AppServicePlanCreateWithNoWait(cli_ctx=cmd.cli_ctx)(command_args={ - "name": name, - "resource_group": resource_group_name, - "location": location, - "tags": tags, - "sku": sku_def.as_dict(), - "reserved": plan_def.reserved, - "hyper_v": plan_def.hyper_v, - "per_site_scaling": plan_def.per_site_scaling, - "hosting_environment_profile": hosting_environment_profile, - "async_scaling_enabled": plan_def.async_scaling_enabled, - "zone_redundant": zone_redundant if zone_redundant else None, - "is_custom_mode": is_managed_instance, - "network": { - "virtual_network_subnet_id": subnet_resource_id, - } if subnet_resource_id else None, - "rdp_enabled": rdp_enabled, - "mi_system_assigned": str(mi_system_assigned) if mi_system_assigned else None, - "mi_user_assigned": mi_user_assigned, - "plan_default_identity": plan_default_identity, - "registry_adapters": registry_adapters, - "install_scripts": install_scripts, - "storage_mounts": storage_mounts, - }) + try: + poller = AppServicePlanCreateWithNoWait(cli_ctx=cmd.cli_ctx)(command_args={ + "name": name, + "resource_group": resource_group_name, + "location": location, + "tags": tags, + "sku": sku_def.as_dict(), + "reserved": plan_def.reserved, + "hyper_v": plan_def.hyper_v, + "per_site_scaling": plan_def.per_site_scaling, + "hosting_environment_profile": hosting_environment_profile, + "async_scaling_enabled": plan_def.async_scaling_enabled, + "zone_redundant": zone_redundant if zone_redundant else None, + "is_custom_mode": is_managed_instance, + "network": { + "virtual_network_subnet_id": subnet_resource_id, + } if subnet_resource_id else None, + "rdp_enabled": rdp_enabled, + "mi_system_assigned": str(mi_system_assigned) if mi_system_assigned else None, + "mi_user_assigned": mi_user_assigned, + "plan_default_identity": plan_default_identity, + "registry_adapters": registry_adapters, + "install_scripts": install_scripts, + "storage_mounts": storage_mounts, + }) - if no_wait: - return poller.result() + if no_wait: + return poller.result() - # Check if this is an asynchronous operation - is_async = is_async_response(poller) + # Check if this is an asynchronous operation + is_async = is_async_response(poller) - if not is_async: - # for synchronous operations, or if we are unable to get the initial response, directly return poller result - return poller.result() + if not is_async: + # for synchronous operations, or if we are unable to get the initial response, directly return poller result + return poller.result() - # Asynchronous operation (202 response), use custom progress bar - progress_bar = PlanProgressBar(cmd.cli_ctx, resource_group_name, name) - return LongRunningOperation(cmd.cli_ctx, progress_bar=progress_bar)(poller) + # Asynchronous operation (202 response), use custom progress bar + progress_bar = PlanProgressBar(cmd.cli_ctx, resource_group_name, name) + return LongRunningOperation(cmd.cli_ctx, progress_bar=progress_bar)(poller) + except EnrichedDeploymentError: + raise + except Exception as ex: # pylint: disable=broad-except + if not (enriched_errors and is_linux): + raise + _raise_enriched_plan_create_error(ex, resource_group_name, name, location, sku) def update_app_service_plan_with_progress(cmd, resource_group_name, name, app_service_plan): diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_deployment_context_engine.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_deployment_context_engine.py index f7f02a14545..aade1c5c139 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_deployment_context_engine.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_deployment_context_engine.py @@ -14,8 +14,10 @@ from azure.cli.command_modules.appservice._deployment_failure_patterns import ( DEPLOYMENT_FAILURE_PATTERNS, + CONTROL_PLANE_FAILURE_PATTERNS, get_failure_pattern, match_failure_pattern, + match_control_plane_failure_pattern, ) from azure.cli.command_modules.appservice._deployment_context_engine import ( build_enriched_error_context, @@ -24,6 +26,9 @@ extract_status_code_from_message, EnrichedDeploymentError, _determine_deployment_type, + build_enriched_plan_error_context, + format_enriched_plan_error_message, + raise_enriched_plan_error, ) @@ -450,5 +455,183 @@ def test_200_not_extracted(self): self.assertIsNone(extract_status_code_from_message("Status Code: 200")) +# --------------------------------------------------------------------------- +# Tests for control-plane failure patterns (az appservice plan create) +# --------------------------------------------------------------------------- +class TestControlPlaneFailurePatterns(unittest.TestCase): + """Tests for the ARM control-plane patterns used by az appservice plan create.""" + + def test_all_patterns_have_required_keys(self): + required_keys = {"errorCode", "stage", "suggestedFixes"} + for pattern in CONTROL_PLANE_FAILURE_PATTERNS: + with self.subTest(errorCode=pattern["errorCode"]): + self.assertTrue(required_keys.issubset(pattern.keys())) + self.assertIsInstance(pattern["suggestedFixes"], list) + self.assertGreater(len(pattern["suggestedFixes"]), 0) + + def test_get_failure_pattern_resolves_control_plane(self): + """get_failure_pattern should also look up control-plane error codes.""" + pattern = get_failure_pattern("LocationNotAvailable") + self.assertIsNotNone(pattern) + self.assertEqual(pattern["errorCode"], "LocationNotAvailable") + self.assertEqual(pattern["stage"], "ResourceProvisioning") + + # --- message-based matching --- + def test_match_quota_exceeded(self): + p = match_control_plane_failure_pattern( + status_code=401, + error_message="Operation could not be completed as it results in exceeding approved Quota") + self.assertEqual(p["errorCode"], "QuotaExceeded") + + def test_match_authorization_failed_message(self): + p = match_control_plane_failure_pattern( + status_code=403, + error_message="AuthorizationFailed: The client does not have authorization to perform action") + self.assertEqual(p["errorCode"], "AuthorizationFailed") + + def test_match_missing_subscription_registration(self): + p = match_control_plane_failure_pattern( + status_code=409, + error_message="The subscription is not registered to use namespace 'Microsoft.Web'") + self.assertEqual(p["errorCode"], "MissingSubscriptionRegistration") + + def test_match_resource_group_not_found(self): + p = match_control_plane_failure_pattern( + status_code=404, + error_message="Resource group 'foo' could not be found.") + self.assertEqual(p["errorCode"], "ResourceGroupNotFound") + + def test_match_sku_not_available(self): + p = match_control_plane_failure_pattern( + status_code=400, + error_message="The requested SKU is not available in the selected location") + self.assertEqual(p["errorCode"], "SkuNotAvailable") + + def test_match_location_not_available(self): + p = match_control_plane_failure_pattern( + status_code=400, + error_message="The provided location 'nowhereland' is not available for resource type " + "'Microsoft.Web/serverFarms'.") + self.assertEqual(p["errorCode"], "LocationNotAvailable") + + def test_match_zone_redundancy_unsupported(self): + p = match_control_plane_failure_pattern( + status_code=400, + error_message="Zone redundancy is not supported for this configuration") + self.assertEqual(p["errorCode"], "ZoneRedundancyUnsupported") + + # --- status-code fallbacks --- + def test_match_403_fallback(self): + p = match_control_plane_failure_pattern(status_code=403, error_message="forbidden") + self.assertEqual(p["errorCode"], "AuthorizationFailed") + + def test_match_404_fallback(self): + p = match_control_plane_failure_pattern(status_code=404, error_message="missing") + self.assertEqual(p["errorCode"], "ResourceGroupNotFound") + + def test_match_409_fallback(self): + p = match_control_plane_failure_pattern(status_code=409, error_message="conflict") + self.assertEqual(p["errorCode"], "MissingSubscriptionRegistration") + + def test_match_400_unmatched_returns_none(self): + """An unspecific 400 should not be force-classified as SkuNotAvailable.""" + p = match_control_plane_failure_pattern(status_code=400, error_message="bad request") + self.assertIsNone(p) + + def test_match_no_match(self): + p = match_control_plane_failure_pattern(status_code=200, error_message="all good") + self.assertIsNone(p) + + +# --------------------------------------------------------------------------- +# Tests for plan-create enrichment (build / format / raise) +# --------------------------------------------------------------------------- +class TestPlanCreateEnrichment(unittest.TestCase): + """Tests for the App Service plan creation enrichment engine.""" + + def test_build_context_with_known_pattern(self): + ctx = build_enriched_plan_error_context( + resource_group_name="test-rg", + plan_name="test-plan", + location="nowhereland", + sku="B1", + status_code=400, + error_message="The provided location 'nowhereland' is not available for resource type " + "'Microsoft.Web/serverFarms'.", + last_known_step="App Service Plan create (control-plane request)", + ) + self.assertEqual(ctx["errorCode"], "LocationNotAvailable") + self.assertEqual(ctx["stage"], "ResourceProvisioning") + self.assertEqual(ctx["resourceGroup"], "test-rg") + self.assertEqual(ctx["planName"], "test-plan") + self.assertEqual(ctx["region"], "nowhereland") + self.assertEqual(ctx["planSku"], "B1") + self.assertEqual(ctx["lastKnownStep"], "App Service Plan create (control-plane request)") + self.assertIn("rawError", ctx) + self.assertIn("suggestedFixes", ctx) + + def test_build_context_with_unknown_error(self): + ctx = build_enriched_plan_error_context( + resource_group_name="test-rg", + plan_name="test-plan", + status_code=502, + error_message="Something weird happened", + ) + self.assertEqual(ctx["errorCode"], "HTTP_502") + self.assertEqual(ctx["stage"], "ResourceProvisioning") + self.assertIn("rawError", ctx) + + def test_build_context_defaults_for_missing_fields(self): + ctx = build_enriched_plan_error_context(error_message="boom") + self.assertEqual(ctx["errorCode"], "UnknownPlanCreateError") + self.assertEqual(ctx["resourceGroup"], "Unknown") + self.assertEqual(ctx["planName"], "Unknown") + self.assertEqual(ctx["region"], "Unknown") + self.assertEqual(ctx["planSku"], "Unknown") + + def test_build_context_truncates_long_error(self): + long_msg = "x" * 600 + ctx = build_enriched_plan_error_context(status_code=400, error_message=long_msg) + self.assertTrue(ctx["rawError"].endswith("... [truncated]")) + self.assertLessEqual(len(ctx["rawError"]), 520) + + def test_format_message_contains_key_sections(self): + ctx = build_enriched_plan_error_context( + resource_group_name="test-rg", + plan_name="test-plan", + location="eastus", + sku="P1V3", + status_code=403, + error_message="AuthorizationFailed: not authorized", + ) + msg = format_enriched_plan_error_message(ctx) + self.assertIn("APP SERVICE PLAN CREATION FAILED", msg) + self.assertIn("AuthorizationFailed", msg) + self.assertIn("Plan Name : test-plan", msg) + self.assertIn("Resource Grp: test-rg", msg) + self.assertIn("Region : eastus", msg) + self.assertIn("Plan SKU : P1V3", msg) + self.assertIn("Suggested Fixes:", msg) + self.assertIn("GitHub Copilot Chat", msg) + # Should be plan-specific, not deployment + self.assertNotIn("DEPLOYMENT FAILED", msg) + + def test_raise_enriched_plan_error(self): + with self.assertRaises(EnrichedDeploymentError) as cm: + raise_enriched_plan_error( + resource_group_name="test-rg", + plan_name="test-plan", + location="nowhereland", + sku="B1", + status_code=400, + error_message="The provided location 'nowhereland' is not available for resource type " + "'Microsoft.Web/serverFarms'.", + ) + error_msg = str(cm.exception) + self.assertIn("APP SERVICE PLAN CREATION FAILED", error_msg) + self.assertIn("LocationNotAvailable", error_msg) + self.assertIn("Pick a region where App Service plans", error_msg) + + if __name__ == '__main__': unittest.main()