diff --git a/src/site/azext_site/_help.py b/src/site/azext_site/_help.py index 126d5d00714..058091b3cfc 100644 --- a/src/site/azext_site/_help.py +++ b/src/site/azext_site/_help.py @@ -9,3 +9,11 @@ # pylint: disable=too-many-lines from knack.help_files import helps # pylint: disable=unused-import + +helps['site quickstart'] = """ +type: command +short-summary: Quickstart deploy Site + Config + ConfigRef using an internal ARM template. +examples: + - name: Resource group scope + text: az site quickstart --name MySite01 --defaultconfiguration -g MyRG +""" diff --git a/src/site/azext_site/aaz/latest/site/__init__.py b/src/site/azext_site/aaz/latest/site/__init__.py index c401f439385..e2ef26b7f37 100644 --- a/src/site/azext_site/aaz/latest/site/__init__.py +++ b/src/site/azext_site/aaz/latest/site/__init__.py @@ -14,3 +14,4 @@ from ._list import * from ._show import * from ._update import * +from ._quickstart import * \ No newline at end of file diff --git a/src/site/azext_site/aaz/latest/site/_quickstart.py b/src/site/azext_site/aaz/latest/site/_quickstart.py new file mode 100644 index 00000000000..0e8a3abe6bf --- /dev/null +++ b/src/site/azext_site/aaz/latest/site/_quickstart.py @@ -0,0 +1,219 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +from pathlib import Path +from azure.cli.core.aaz import ( # type: ignore[import-unresolved] + AAZCommand, + AAZStrArg, + AAZStrArgFormat, + AAZBoolArg, + AAZResourceGroupNameArg, + has_value, + register_command, + AAZResourceLocationArg +) +from azure.cli.core.azclierror import ( # type: ignore[import-unresolved] + InvalidArgumentValueError, + FileOperationError, + CLIInternalError, +) +from azure.cli.core import get_default_cli # type: ignore[import-unresolved] +from knack.log import get_logger + +logger = get_logger(__name__) + + +def _resolve_template_path() -> Path: + # ...\azext_site\aaz\latest\site\_quickstart.py -> ...\azext_site\templates\infra\main.json + azext_root = Path(__file__).resolve().parents[3] # ...\azext_site + return azext_root / "templates" / "infra" / "main.json" + + +@register_command("site quickstart") +class Quickstart(AAZCommand): + """Quickstart: deploy internal ARM template to create Site + Config + ConfigRef.""" + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + _args_schema = cls._args_schema + + _args_schema.name = AAZStrArg( + options=["-n", "--name"], + required=True, + help="Site name (siteName).", + fmt=AAZStrArgFormat( + pattern=r"^[a-zA-Z0-9][a-zA-Z0-9-_.]{0,62}[a-zA-Z0-9]$", + min_length=2, + max_length=64, + ), + ) + + _args_schema.defaultconfiguration = AAZBoolArg( + options=["--defaultconfiguration", "--default-configuration"], + help="Trigger the internal ARM template flow (Site + Config + ConfigRef).", + ) + + _args_schema.resource_group = AAZResourceGroupNameArg( + options=["-g", "--resource-group"], + required=True, + help="Resource group for deployment.", + ) + + _args_schema.location = AAZResourceLocationArg( + options=["-l", "--location"], + help="Location for the deployment. Default: resource group location.", + ) + + _args_schema.config_name = AAZStrArg( + options=["--config-name"], + help="Optional configName override. Default in template: 'siteName-configuration'.", + ) + + return cls._args_schema + + def _handler(self, command_args): + super()._handler(command_args) + + if not self.ctx.args.defaultconfiguration: + raise InvalidArgumentValueError("Specify --defaultconfiguration to run quickstart.") + + return self.handle() + + def handle(self): + template = _resolve_template_path() + if not template.exists(): + raise FileOperationError(f"Internal ARM template not found: {template}") + + site_name = self.ctx.args.name.to_serialized_data() + rg = self.ctx.args.resource_group.to_serialized_data() + deployment_name = f"site-quickstart-{site_name}" + + invoke_args = [ + "deployment", "group", "create", + "--name", deployment_name, + "--resource-group", rg, + "--template-file", str(template), + "--parameters", f"siteName={site_name}", + "--only-show-errors", + "--output", "none", + ] + + if has_value(self.ctx.args.location): + loc = self.ctx.args.location.to_serialized_data() + invoke_args.extend(["--parameters", f"location={loc}"]) + + if has_value(self.ctx.args.config_name): + cfg = self.ctx.args.config_name.to_serialized_data() + invoke_args.extend(["--parameters", f"configName={cfg}"]) + + cli = get_default_cli() + rc = cli.invoke(invoke_args) + if rc != 0: + # Capture the original error first (before more invokes overwrite cli.result) + underlying_error = None + if getattr(cli, "result", None) is not None: + underlying_error = getattr(cli.result, "error", None) + + deployment_error = None + failed_ops = None + + # Try to fetch ARM deployment error object (code/message/details) + try: + show_args = [ + "deployment", "group", "show", + "--name", deployment_name, + "--resource-group", rg, + "--only-show-errors", + "--query", "properties.error", + "--output", "json", + ] + cli.invoke(show_args) + if getattr(cli, "result", None) is not None: + deployment_error = cli.result.result + except Exception: + deployment_error = None + + # Try to fetch failed operations (often contains the most actionable message) + try: + ops_args = [ + "deployment", "operation", "group", "list", + "--name", deployment_name, + "--resource-group", rg, + "--only-show-errors", + "--query", + "[?properties.provisioningState=='Failed']." + "{type:properties.targetResource.resourceType," + " name:properties.targetResource.resourceName," + " statusMessage:properties.statusMessage}", + "--output", "json", + ] + cli.invoke(ops_args) + if getattr(cli, "result", None) is not None: + failed_ops = cli.result.result + except Exception: + failed_ops = None + + msg = ( + "ARM deployment failed for site quickstart. " + f"Deployment name: {deployment_name}, resource group: {rg}." + ) + if underlying_error: + msg = f"{msg}\nUnderlying error: {underlying_error}" + + if deployment_error: + msg = f"{msg}\nDeployment error:\n{json.dumps(deployment_error, indent=2)}" + + if failed_ops: + msg = f"{msg}\nFailed operations:\n{json.dumps(failed_ops, indent=2)}" + + raise CLIInternalError(msg) + + # 2) Query deployment operations and print friendly success messages + ops_args = [ + "deployment", "operation", "group", "list", + "--name", deployment_name, + "--resource-group", rg, + "--only-show-errors", + "--output", "none", + ] + cli.invoke(ops_args) + ops = [] + if getattr(cli, "result", None) is not None: + ops = cli.result.result or [] + + succeeded_types = set() + if isinstance(ops, list): + for op in ops: + if not isinstance(op, dict): + continue + props = op.get("properties") or {} + if not isinstance(props, dict): + continue + if props.get("provisioningState") != "Succeeded": + continue + tr = props.get("targetResource") or {} + if isinstance(tr, dict): + rtype = tr.get("resourceType") + if rtype: + succeeded_types.add(rtype) + + if "Microsoft.Edge/sites" in succeeded_types: + print("Site created successfully.") + if "Microsoft.Edge/Configurations" in succeeded_types: + print("Config created successfully.") + if "Microsoft.Edge/configurationReferences" in succeeded_types: + print("Config reference created successfully.") + + if not ({"Microsoft.Edge/sites", "Microsoft.Edge/Configurations", "Microsoft.Edge/configurationReferences"} & succeeded_types): + print("Deployment completed successfully.") + + return None \ No newline at end of file diff --git a/src/site/azext_site/templates/infra/main.json b/src/site/azext_site/templates/infra/main.json new file mode 100644 index 00000000000..f530de6d040 --- /dev/null +++ b/src/site/azext_site/templates/infra/main.json @@ -0,0 +1,173 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "siteApiVersion": { + "type": "string", + "defaultValue": "2025-06-01" + }, + "configApiVersion": { + "type": "string", + "defaultValue": "2025-06-01" + }, + "configChildApiVersion": { + "type": "string", + "defaultValue": "2024-09-01-preview" + }, + "configRefApiVersion": { + "type": "string", + "defaultValue": "2025-06-01" + }, + "siteName": { + "type": "string" + }, + "resourceGroupName": { + "type": "string", + "defaultValue": "[resourceGroup().name]" + }, + "description": { + "type": "string", + "defaultValue": "" + }, + "labels": { + "type": "object", + "defaultValue": {} + }, + "siteAddress": { + "type": "object", + "defaultValue": { + "streetAddress1": "", + "streetAddress2": "", + "city": "", + "stateOrProvince": "", + "country": "", + "postalCode": "" + } + }, + "location": { + "type": "string", + "defaultValue": "eastus" + }, + "configName": { + "type": "string", + "defaultValue": "[concat(parameters('siteName'), '-configuration')]" + }, + "connectivityConfigName": { + "type": "string", + "defaultValue": "connectivityConfig1" + }, + "secretConfigName": { + "type": "string", + "defaultValue": "secretconfig1" + }, + "networkConfigName": { + "type": "string", + "defaultValue": "networkConfig1" + }, + "networkConfigurationKind": { + "type": "string", + "defaultValue": "LAN" + }, + "tsConfigName": { + "type": "string", + "defaultValue": "tsconfig1" + }, + "timeServerConfiguration": { + "type": "object", + "defaultValue": {} + }, + "connectivityConfiguration": { + "type": "object", + "defaultValue": {} + }, + "securityConfiguration": { + "type": "object", + "defaultValue": {} + }, + "networkConfiguration": { + "type": "object", + "defaultValue": { + "scenario": "Provisioning", + "ipAssignments": { + "ipAssignmentType": "Manual", + "ipv4": { + "addressRange": { + "startIp": "192.168.100.10", + "endIp": "192.168.100.50" + }, + "subnetMask": "255.255.255.0", + "defaultGateway": "192.168.100.1", + "dnsServers": [], + "vLanId": 0 + } + } + } + } + }, + "variables": { + "siteId": "[resourceId('Microsoft.Edge/sites', parameters('siteName'))]", + "configId": "[resourceId('Microsoft.Edge/Configurations', parameters('configName'))]" + }, + "resources": [ + { + "type": "Microsoft.Edge/sites", + "apiVersion": "[parameters('siteApiVersion')]", + "name": "[parameters('siteName')]", + "properties": { + "displayName": "[parameters('siteName')]", + "description": "[parameters('description')]", + "siteAddress": { + "streetAddress1": "[parameters('siteAddress').streetAddress1]", + "streetAddress2": "[parameters('siteAddress').streetAddress2]", + "city": "[parameters('siteAddress').city]", + "stateOrProvince": "[parameters('siteAddress').stateOrProvince]", + "country": "[parameters('siteAddress').country]", + "postalCode": "[parameters('siteAddress').postalCode]" + }, + "labels": "[parameters('labels')]" + } + }, + { + "type": "Microsoft.Edge/Configurations", + "apiVersion": "[parameters('configApiVersion')]", + "name": "[parameters('configName')]", + "location": "[parameters('location')]", + "properties": {}, + "resources": [ + { + "type": "NetworkConfigurations", + "apiVersion": "[parameters('configChildApiVersion')]", + "name": "[parameters('networkConfigName')]", + "dependsOn": [ + "[resourceId('Microsoft.Edge/Configurations', parameters('configName'))]" + ], + "kind": "[parameters('networkConfigurationKind')]", + "properties": "[parameters('networkConfiguration')]" + } + ] + }, + { + "type": "Microsoft.Edge/configurationReferences", + "apiVersion": "[parameters('configRefApiVersion')]", + "name": "default", + "scope": "[variables('siteId')]", + "dependsOn": [ + "[variables('siteId')]", + "[variables('configId')]" + ], + "properties": { + "configurationResourceId": "[variables('configId')]" + } + } + ], + "outputs": { + "siteId": { + "type": "string", + "value": "[variables('siteId')]" + }, + "configId": { + "type": "string", + "value": "[variables('configId')]" + } + } +} \ No newline at end of file diff --git a/src/site/azext_site/tests/latest/test_site.py b/src/site/azext_site/tests/latest/test_site.py index 9e791a28a41..768e8fdc9b2 100644 --- a/src/site/azext_site/tests/latest/test_site.py +++ b/src/site/azext_site/tests/latest/test_site.py @@ -114,6 +114,23 @@ def test_edge_site_crud(self): ], ) + #Quickstart deploy (Site + Config + ConfigRef) + site_name = self.create_random_name(prefix="clitestqs", length=24) + deployment_name = f"site-quickstart-{site_name}" + self.kwargs.update({ + "qs_site": site_name, + "qs_deployment": deployment_name, + }) + self.cmd( + "az site quickstart -g {rg} -n {qs_site} --defaultconfiguration", + checks=[ + self.check("name", "{qs_deployment}"), + self.check("properties.provisioningState", "Succeeded"), + self.exists("properties.outputs.siteId.value"), + self.exists("properties.outputs.configId.value"), + ], + ) + #List Sites at resource group scope result = self.cmd( "az site list -g {rg}" @@ -131,3 +148,5 @@ def test_edge_site_crud(self): #Delete Site at subscription scope self.cmd("az site delete --site-name TestSubsSiteName --yes") + + \ No newline at end of file diff --git a/src/site/setup.cfg b/src/site/setup.cfg index 2fdd96e5d39..67b297a94f2 100644 --- a/src/site/setup.cfg +++ b/src/site/setup.cfg @@ -1 +1,5 @@ -#setup.cfg \ No newline at end of file +#setup.cfg + +[options.package_data] +azext_site = + templates\**\*.json \ No newline at end of file