diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index cd5519fff1d..d22ecaad137 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -2148,7 +2148,7 @@ type: command short-summary: Create app service environment. examples: - - name: Create Resource Group, vNet and app service environment with default values. + - name: Create Resource Group, vNet and app service environment v2 with default values. text: | az group create -g MyResourceGroup --location westeurope @@ -2157,27 +2157,50 @@ az appservice ase create -n MyAseName -g MyResourceGroup --vnet-name MyVirtualNetwork \\ --subnet MyAseSubnet - - name: Create External app service environments with large front-ends and scale factor of 10 in existing resource group and vNet. + - name: Create External app service environments v2 with large front-ends and scale factor of 10 in existing resource group and vNet. text: | az appservice ase create -n MyAseName -g MyResourceGroup --vnet-name MyVirtualNetwork \\ --subnet MyAseSubnet --front-end-sku I3 --front-end-scale-factor 10 \\ --virtual-ip-type External - - name: Create vNet and app service environment, but do not create network security group and route table in existing resource group. + - name: Create vNet and app service environment v2, but do not create network security group and route table in existing resource group. text: | az network vnet create -g MyResourceGroup -n MyVirtualNetwork \\ --address-prefixes 10.0.0.0/16 --subnet-name MyAseSubnet --subnet-prefixes 10.0.0.0/24 az appservice ase create -n MyAseName -g MyResourceGroup --vnet-name MyVirtualNetwork \\ --subnet MyAseSubnet --ignore-network-security-group --ignore-route-table - - name: Create vNet and app service environment in a smaller than recommended subnet in existing resource group. + - name: Create vNet and app service environment v2 in a smaller than recommended subnet in existing resource group. text: | az network vnet create -g MyResourceGroup -n MyVirtualNetwork \\ --address-prefixes 10.0.0.0/16 --subnet-name MyAseSubnet --subnet-prefixes 10.0.0.0/26 az appservice ase create -n MyAseName -g MyResourceGroup --vnet-name MyVirtualNetwork \\ --subnet MyAseSubnet --ignore-subnet-size-validation + - name: Create Resource Group, vNet and app service environment v3 with default values. + text: | + az group create -g ASEv3ResourceGroup --location westeurope + + az network vnet create -g ASEv3ResourceGroup -n MyASEv3VirtualNetwork \\ + --address-prefixes 10.0.0.0/16 --subnet-name Inbound --subnet-prefixes 10.0.0.0/24 + + az network vnet subnet create -g ASEv3ResourceGroup --vnet-name MyASEv3VirtualNetwork \\ + --name Outbound --address-prefixes 10.0.1.0/24 + + az appservice ase create -n MyASEv3Name -g ASEv3ResourceGroup \\ + --vnet-name MyASEv3VirtualNetwork --subnet Outbound --kind asev3 +""" + +helps['appservice ase create-inbound-services'] = """ + type: command + short-summary: Create the inbound services needed in preview for ASEv3 (private endpoint and DNS). + examples: + - name: Create private endpoint, Private DNS Zone, A records and ensure subnet network policy. + text: | + az appservice ase create-inbound-services -n MyASEv3Name -g ASEv3ResourceGroup \\ + --vnet-name MyASEv3VirtualNetwork --subnet Inbound """ + helps['appservice ase update'] = """ type: command short-summary: Update app service environment. 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 b2279200c68..228841d5b5e 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -35,6 +35,7 @@ WINDOWS_RUNTIMES = ['dotnet', 'node', 'java', 'powershell'] ACCESS_RESTRICTION_ACTION_TYPES = ['Allow', 'Deny'] ASE_LOADBALANCER_MODES = ['Internal', 'External'] +ASE_KINDS = ['ASEv2', 'ASEv3'] # pylint: disable=too-many-statements, too-many-lines @@ -45,10 +46,10 @@ def load_arguments(self, _): # PARAMETER REGISTRATION name_arg_type = CLIArgumentType(options_list=['--name', '-n'], metavar='NAME') sku_arg_type = CLIArgumentType( - help='The pricing tiers, e.g., F1(Free), D1(Shared), B1(Basic Small), B2(Basic Medium), B3(Basic Large), S1(Standard Small), P1V2(Premium V2 Small), P1V3(Premium V3 Small), P2V3(Premium V3 Medium), P3V3(Premium V3 Large), PC2 (Premium Container Small), PC3 (Premium Container Medium), PC4 (Premium Container Large), I1 (Isolated Small), I2 (Isolated Medium), I3 (Isolated Large)', + help='The pricing tiers, e.g., F1(Free), D1(Shared), B1(Basic Small), B2(Basic Medium), B3(Basic Large), S1(Standard Small), P1V2(Premium V2 Small), P1V3(Premium V3 Small), P2V3(Premium V3 Medium), P3V3(Premium V3 Large), PC2 (Premium Container Small), PC3 (Premium Container Medium), PC4 (Premium Container Large), I1 (Isolated Small), I2 (Isolated Medium), I3 (Isolated Large), I1v2 (Isolated V2 Small), I2v2 (Isolated V2 Medium), I3v2 (Isolated V2 Large)', arg_type=get_enum_type( ['F1', 'FREE', 'D1', 'SHARED', 'B1', 'B2', 'B3', 'S1', 'S2', 'S3', 'P1V2', 'P2V2', 'P3V2', 'P1V3', 'P2V3', 'P3V3', 'PC2', 'PC3', - 'PC4', 'I1', 'I2', 'I3'])) + 'PC4', 'I1', 'I2', 'I3', 'I1v2', 'I2v2', 'I3v2'])) webapp_name_arg_type = CLIArgumentType(configured_default='web', options_list=['--name', '-n'], metavar='NAME', completer=get_resource_name_completion_list('Microsoft.Web/sites'), id_part='name', @@ -869,6 +870,8 @@ def load_arguments(self, _): help='Name of the app service environment', local_context_attribute=LocalContextAttribute(name='ase_name', actions=[LocalContextAction.SET], scopes=['appservice'])) + c.argument('kind', options_list=['--kind', '-k'], arg_type=get_enum_type(ASE_KINDS), + default='ASEv2', help="Specify App Service Environment version") c.argument('subnet', help='Name or ID of existing subnet. To create vnet and/or subnet \ use `az network vnet [subnet] create`') c.argument('vnet_name', help='Name of the vNet. Mandatory if only subnet name is specified.') @@ -902,6 +905,12 @@ def load_arguments(self, _): with self.argument_context('appservice ase list-plans') as c: c.argument('name', options_list=['--name', '-n'], help='Name of the app service environment', local_context_attribute=LocalContextAttribute(name='ase_name', actions=[LocalContextAction.GET])) + with self.argument_context('appservice ase create-inbound-services') as c: + c.argument('name', options_list=['--name', '-n'], help='Name of the app service environment', + local_context_attribute=LocalContextAttribute(name='ase_name', actions=[LocalContextAction.GET])) + c.argument('subnet', help='Name or ID of existing subnet for inbound traffic to ASEv3. \ + To create vnet and/or subnet use `az network vnet [subnet] create`') + c.argument('vnet_name', help='Name of the vNet. Mandatory if only subnet name is specified.') # App Service Domain Commands with self.argument_context('appservice domain create') as c: diff --git a/src/azure-cli/azure/cli/command_modules/appservice/appservice_environment.py b/src/azure-cli/azure/cli/command_modules/appservice/appservice_environment.py index 70bfed8cfbe..a87d089342b 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/appservice_environment.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/appservice_environment.py @@ -7,18 +7,22 @@ from azure.mgmt.network import NetworkManagementClient from azure.mgmt.web import WebSiteManagementClient from azure.mgmt.resource import ResourceManagementClient +from azure.mgmt.privatedns import PrivateDnsManagementClient # Models -from azure.mgmt.network.models import (RouteTable, Route, NetworkSecurityGroup, SecurityRule) -from azure.mgmt.resource.resources.models import (DeploymentProperties, Deployment) +from azure.mgmt.network.models import (RouteTable, Route, NetworkSecurityGroup, SecurityRule, Delegation, + PrivateEndpoint, Subnet, PrivateLinkServiceConnection) +from azure.mgmt.resource.resources.models import (DeploymentProperties, Deployment, SubResource) +from azure.mgmt.privatedns.models import (PrivateZone, VirtualNetworkLink, RecordSet, ARecord) # Utils from azure.cli.core.commands import LongRunningOperation from azure.cli.core.commands.client_factory import (get_mgmt_service_client, get_subscription_id) from azure.cli.core.commands.arm import ArmTemplateBuilder from azure.cli.core.util import (sdk_no_wait, random_string) +from azure.cli.core.azclierror import (ResourceNotFoundError, ValidationError, CommandNotFoundError, + MutuallyExclusiveArgumentError) from knack.log import get_logger -from knack.util import CLIError from msrestazure.tools import (parse_resource_id, is_valid_resource_id, resource_id) VERSION_2019_10_01 = "2019-10-01" @@ -41,7 +45,8 @@ def show_appserviceenvironment(cmd, name, resource_group_name=None): return ase_client.get(resource_group_name, name) -def create_appserviceenvironment_arm(cmd, resource_group_name, name, subnet, vnet_name=None, ignore_route_table=False, +def create_appserviceenvironment_arm(cmd, resource_group_name, name, subnet, kind='ASEv2', + vnet_name=None, ignore_route_table=False, ignore_network_security_group=False, virtual_ip_type='Internal', front_end_scale_factor=None, front_end_sku=None, force_route_table=False, force_network_security_group=False, ignore_subnet_size_validation=False, @@ -53,23 +58,28 @@ def create_appserviceenvironment_arm(cmd, resource_group_name, name, subnet, vne # Therefore the current method use direct ARM. location = location or _get_location_from_resource_group(cmd.cli_ctx, resource_group_name) subnet_id = _validate_subnet_id(cmd.cli_ctx, subnet, vnet_name, resource_group_name) - + deployment_name = _get_unique_deployment_name('cli_ase_deploy_') _validate_subnet_empty(cmd.cli_ctx, subnet_id) if not ignore_subnet_size_validation: _validate_subnet_size(cmd.cli_ctx, subnet_id) - if not ignore_route_table: - _ensure_route_table(cmd.cli_ctx, resource_group_name, name, location, subnet_id, force_route_table) - if not ignore_network_security_group: - _ensure_network_security_group(cmd.cli_ctx, resource_group_name, name, location, - subnet_id, force_network_security_group) + if kind == 'ASEv2': + if not ignore_route_table: + _ensure_route_table(cmd.cli_ctx, resource_group_name, name, location, subnet_id, force_route_table) + if not ignore_network_security_group: + _ensure_network_security_group(cmd.cli_ctx, resource_group_name, name, location, + subnet_id, force_network_security_group) + ase_deployment_properties = _build_ase_deployment_properties(name=name, location=location, + subnet_id=subnet_id, + virtual_ip_type=virtual_ip_type, + front_end_scale_factor=front_end_scale_factor, + front_end_sku=front_end_sku) + + elif kind == 'ASEv3': + _ensure_subnet_delegation(cmd.cli_ctx, subnet_id, 'Microsoft.Web/hostingEnvironments') + ase_deployment_properties = _build_ase_deployment_properties(name=name, location=location, + subnet_id=subnet_id, kind='ASEv3') logger.info('Create App Service Environment...') - deployment_name = _get_unique_deployment_name('cli_ase_deploy_') - ase_deployment_properties = _build_ase_deployment_properties(name=name, location=location, - subnet_id=subnet_id, virtual_ip_type=virtual_ip_type, - front_end_scale_factor=front_end_scale_factor, - front_end_sku=front_end_sku, tags=None) - deployment_client = _get_resource_client_factory(cmd.cli_ctx).deployments return sdk_no_wait(no_wait, deployment_client.create_or_update, resource_group_name, deployment_name, ase_deployment_properties) @@ -90,6 +100,9 @@ def update_appserviceenvironment(cmd, name, resource_group_name=None, front_end_ if resource_group_name is None: resource_group_name = _get_resource_group_name_from_ase(ase_client, name) ase_def = ase_client.get(resource_group_name, name) + if ase_def.kind.lower() != 'asev2': + raise CommandNotFoundError('Only ASEv2 currently supports update') + worker_sku = _map_worker_sku(front_end_sku) ase_def.worker_pools = ase_def.worker_pools or [] # v1 feature, but cannot be null ase_def.internal_load_balancing_mode = None # Workaround issue with flag enums in Swagger @@ -106,6 +119,10 @@ def list_appserviceenvironment_addresses(cmd, name, resource_group_name=None): ase_client = _get_ase_client_factory(cmd.cli_ctx) if resource_group_name is None: resource_group_name = _get_resource_group_name_from_ase(ase_client, name) + ase = ase_client.get(resource_group_name, name) + if ase.kind.lower() == 'asev3': + raise CommandNotFoundError('list-addresses is currently not supported for ASEv3. ' + 'Inbound IP is associated with the private endpoint.') return ase_client.get_vip_info(resource_group_name, name) @@ -116,6 +133,61 @@ def list_appserviceenvironment_plans(cmd, name, resource_group_name=None): return ase_client.list_app_service_plans(resource_group_name, name) +def create_asev3_inbound_services(cmd, resource_group_name, name, subnet, vnet_name=None): + ase_client = _get_ase_client_factory(cmd.cli_ctx) + ase = ase_client.get(resource_group_name, name) + if not ase: + raise ResourceNotFoundError("App Service Environment '{}' not found.".format(name)) + if ase.kind.lower() != 'asev3': + raise CommandNotFoundError('Only ASEv3 support private endpoint connections') + + inbound_subnet_id = _validate_subnet_id(cmd.cli_ctx, subnet, vnet_name, resource_group_name) + inbound_vnet_id = _get_vnet_id_from_subnet(cmd.cli_ctx, inbound_subnet_id) + _ensure_subnet_private_endpoint_network_policy(cmd.cli_ctx, inbound_subnet_id, False) + + # Private Endpoint + network_client = _get_network_client_factory(cmd.cli_ctx) + pls_connection = PrivateLinkServiceConnection(private_link_service_id=ase.id, + group_ids=['hostingEnvironments'], + request_message='Link from CLI', + name='{}-private-connection'.format(name)) + private_endpoint = PrivateEndpoint( + location=ase.location, + tags=None, + subnet=Subnet(id=inbound_subnet_id) + ) + + private_endpoint.private_link_service_connections = [pls_connection] + + poller = network_client.private_endpoints.begin_create_or_update(resource_group_name, + '{}-private-endpoint'.format(name), + private_endpoint) + LongRunningOperation(cmd.cli_ctx)(poller) + ase_pe = poller.result() + nic_name = parse_resource_id(ase_pe.network_interfaces[0].id)['name'] + nic = network_client.network_interfaces.get(resource_group_name, nic_name) + + # Private DNS Zone + private_dns_client = _get_private_dns_client_factory(cmd.cli_ctx) + zone_name = '{}.appserviceenvironment.net'.format(name) + zone = PrivateZone(location='global', tags=None) + poller = private_dns_client.private_zones.create_or_update(resource_group_name, zone_name, zone, if_none_match='*') + LongRunningOperation(cmd.cli_ctx)(poller) + + link_name = '{}_link'.format(name) + link = VirtualNetworkLink(location='global', tags=None) + link.virtual_network = SubResource(id=inbound_vnet_id) + link.registration_enabled = False + private_dns_client.virtual_network_links.create_or_update(resource_group_name, zone_name, + link_name, link, if_none_match='*') + ase_record = ARecord(ipv4_address=nic.ip_configurations[0].private_ip_address) + record_set = RecordSet(ttl=3600) + record_set.a_records = [ase_record] + private_dns_client.record_sets.create_or_update(resource_group_name, zone_name, 'a', '*', record_set) + private_dns_client.record_sets.create_or_update(resource_group_name, zone_name, 'a', '@', record_set) + private_dns_client.record_sets.create_or_update(resource_group_name, zone_name, 'a', '*.scm', record_set) + + def _get_ase_client_factory(cli_ctx): client = get_mgmt_service_client(cli_ctx, WebSiteManagementClient).app_service_environments return client @@ -139,6 +211,11 @@ def _get_network_client_factory(cli_ctx, api_version=None): return client +def _get_private_dns_client_factory(cli_ctx): + client = get_mgmt_service_client(cli_ctx, PrivateDnsManagementClient) + return client + + def _get_location_from_resource_group(cli_ctx, resource_group_name): resource_group_client = _get_resource_client_factory(cli_ctx).resource_groups group = resource_group_client.get(resource_group_name) @@ -155,7 +232,7 @@ def _get_resource_group_name_from_ase(ase_client, ase_name): ase_found = True break if not ase_found: - raise CLIError("App service environment '{}' not found in subscription.".format(ase_name)) + raise ResourceNotFoundError("App Service Environment '{}' not found in subscription.".format(ase_name)) return resource_group @@ -172,7 +249,19 @@ def _validate_subnet_id(cli_ctx, subnet, vnet_name, resource_group_name): name=vnet_name, child_type_1='subnets', child_name_1=subnet) - raise CLIError('Usage error: --subnet ID | --subnet NAME --vnet-name NAME') + raise MutuallyExclusiveArgumentError('Please specify either: --subnet ID or (--subnet NAME and --vnet-name NAME)') + + +def _get_vnet_id_from_subnet(cli_ctx, subnet_id): + subnet_id_parts = parse_resource_id(subnet_id) + vnet_resource_group = subnet_id_parts['resource_group'] + vnet_name = subnet_id_parts['name'] + return resource_id( + subscription=get_subscription_id(cli_ctx), + resource_group=vnet_resource_group, + namespace='Microsoft.Network', + type='virtualNetworks', + name=vnet_name) def _map_worker_sku(sku_name): @@ -191,8 +280,8 @@ def _validate_subnet_empty(cli_ctx, subnet_id): subnet_name = subnet_id_parts['resource_name'] network_client = _get_network_client_factory(cli_ctx) subnet_obj = network_client.subnets.get(vnet_resource_group, vnet_name, subnet_name) - if subnet_obj.resource_navigation_links: - raise CLIError('Subnet is not empty.') + if subnet_obj.resource_navigation_links or subnet_obj.service_association_links: + raise ValidationError('Subnet is not empty.') def _validate_subnet_size(cli_ctx, subnet_id): @@ -205,7 +294,62 @@ def _validate_subnet_size(cli_ctx, subnet_id): address = subnet_obj.address_prefix size = int(address[address.index('/') + 1:]) if size > 24: - raise CLIError('Subnet is too small. Should be at least /24.') + err_msg = 'Subnet size could cause scaling issues. Recommended size is at least /24.' + rec_msg = 'Use --ignore-subnet-size-validation to skip size test.' + validation_error = ValidationError(err_msg) + validation_error.set_recommendation(rec_msg) + raise validation_error + + +def _ensure_subnet_private_endpoint_network_policy(cli_ctx, subnet_id, network_policy_enabled): + network_client = _get_network_client_factory(cli_ctx) + subnet_id_parts = parse_resource_id(subnet_id) + vnet_resource_group = subnet_id_parts['resource_group'] + vnet_name = subnet_id_parts['name'] + subnet_name = subnet_id_parts['resource_name'] + subnet_obj = network_client.subnets.get(vnet_resource_group, vnet_name, subnet_name) + target_state = 'Enabled' if network_policy_enabled else 'Disabled' + + if subnet_obj.private_endpoint_network_policies != target_state: + subnet_obj.private_endpoint_network_policies = target_state + try: + poller = network_client.subnets.begin_create_or_update( + vnet_resource_group, vnet_name, subnet_name, subnet_parameters=subnet_obj) + LongRunningOperation(cli_ctx)(poller) + except Exception: + err_msg = 'Subnet must have Private Endpoint Network Policy {}.'.format(target_state) + rec_msg = 'Use: az network vnet subnet update --disable-private-endpoint-network-policies' + validation_error = ValidationError(err_msg) + validation_error.set_recommendation(rec_msg) + raise validation_error + + +def _ensure_subnet_delegation(cli_ctx, subnet_id, delegation_service_name): + network_client = _get_network_client_factory(cli_ctx) + subnet_id_parts = parse_resource_id(subnet_id) + vnet_resource_group = subnet_id_parts['resource_group'] + vnet_name = subnet_id_parts['name'] + subnet_name = subnet_id_parts['resource_name'] + subnet_obj = network_client.subnets.get(vnet_resource_group, vnet_name, subnet_name) + + delegations = subnet_obj.delegations + delegated = False + for d in delegations: + if d.service_name.lower() == delegation_service_name.lower(): + delegated = True + + if not delegated: + subnet_obj.delegations = [Delegation(name="delegation", service_name=delegation_service_name)] + try: + poller = network_client.subnets.begin_create_or_update( + vnet_resource_group, vnet_name, subnet_name, subnet_parameters=subnet_obj) + LongRunningOperation(cli_ctx)(poller) + except Exception: + err_msg = 'Subnet must be delegated to {}.'.format(delegation_service_name) + rec_msg = 'Use: az network vnet subnet update --delegations "{}"'.format(delegation_service_name) + validation_error = ValidationError(err_msg) + validation_error.set_recommendation(rec_msg) + raise validation_error def _ensure_route_table(cli_ctx, resource_group_name, ase_name, location, subnet_id, force): @@ -251,9 +395,12 @@ def _ensure_route_table(cli_ctx, resource_group_name, ase_name, location, subnet route_table_id_parts = parse_resource_id(subnet_obj.route_table.id) rt_name = route_table_id_parts['name'] if rt_name.lower() != ase_route_table_name.lower(): - raise CLIError('Route table already exists. \ - Use --ignore-route-table to use existing route table. \ - Use --force-route-table to replace existing route table') + err_msg = 'Route table already exists.' + rec_msg = 'Use --ignore-route-table to use existing route table ' \ + 'or --force-route-table to replace existing route table' + validation_error = ValidationError(err_msg) + validation_error.set_recommendation(rec_msg) + raise validation_error def _ensure_network_security_group(cli_ctx, resource_group_name, ase_name, location, subnet_id, force): @@ -281,79 +428,32 @@ def _ensure_network_security_group(cli_ctx, resource_group_name, ase_name, locat ase_nsg_name, ase_nsg) LongRunningOperation(cli_ctx)(poller) - logger.info('Ensure Network Security Group Rules...') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-management', - 100, 'Used to manage ASE from public VIP', '*', 'Allow', 'Inbound', - '*', 'AppServiceManagement', '454-455', '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-load-balancer-keep-alive', - 105, 'Allow communication to ASE from Load Balancer', '*', 'Allow', 'Inbound', - '*', 'AzureLoadBalancer', '16001', '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'ASE-internal-inbound', - 110, 'ASE-internal-inbound', '*', 'Allow', 'Inbound', - '*', subnet_address_prefix, '*', '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-HTTP', - 120, 'Allow HTTP', '*', 'Allow', 'Inbound', - '*', '*', '80', '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-HTTPS', - 130, 'Allow HTTPS', '*', 'Allow', 'Inbound', - '*', '*', '443', '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-FTP', - 140, 'Allow FTP', '*', 'Allow', 'Inbound', - '*', '*', '21', '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-FTPS', - 150, 'Allow FTPS', '*', 'Allow', 'Inbound', - '*', '*', '990', '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-FTP-Data', - 160, 'Allow FTP Data', '*', 'Allow', 'Inbound', - '*', '*', '10001-10020', '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-Remote-Debugging', - 170, 'Visual Studio remote debugging', '*', 'Allow', 'Inbound', - '*', '*', '4016-4022', '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Outbound-443', - 100, 'Azure Storage blob', '*', 'Allow', 'Outbound', - '*', '*', '443', '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Outbound-DB', - 110, 'Database', '*', 'Allow', 'Outbound', - '*', '*', '1433', 'Sql') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Outbound-DNS', - 120, 'DNS', '*', 'Allow', 'Outbound', - '*', '*', '53', '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'ASE-internal-outbound', - 130, 'Azure Storage queue', '*', 'Allow', 'Outbound', - '*', '*', '*', subnet_address_prefix) - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Outbound-80', - 140, 'Outbound 80', '*', 'Allow', 'Outbound', - '*', '*', '80', '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Outbound-monitor', - 150, 'Azure Monitor', '*', 'Allow', 'Outbound', - '*', '*', 12000, '*') - _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Outbound-NTP', - 160, 'Clock', '*', 'Allow', 'Outbound', - '*', '*', '123', '*') + _create_asev2_nsg_rules(cli_ctx, resource_group_name, ase_nsg_name, subnet_address_prefix) nsg = network_client.network_security_groups.get(resource_group_name, ase_nsg_name) if not subnet_obj.network_security_group or subnet_obj.network_security_group.id != nsg.id: logger.info('Associate Network Security Group with Subnet...') subnet_obj.network_security_group = NetworkSecurityGroup(id=nsg.id) poller = network_client.subnets.begin_create_or_update( - vnet_resource_group, vnet_name, - subnet_name, subnet_parameters=subnet_obj) + vnet_resource_group, vnet_name, subnet_name, subnet_parameters=subnet_obj) LongRunningOperation(cli_ctx)(poller) else: nsg_id_parts = parse_resource_id(subnet_obj.network_security_group.id) nsg_name = nsg_id_parts['name'] if nsg_name.lower() != ase_nsg_name.lower(): - raise CLIError('Network Security Group already exists. \ - Use --ignore-network-security-group to use existing NSG. \ - Use --force-network-security-group to replace existing NSG') + err_msg = 'Network Security Group already exists.' + rec_msg = 'Use --ignore-network-security-group to use existing NSG ' \ + 'or --force-network-security-group to replace existing NSG' + validation_error = ValidationError(err_msg, rec_msg) + raise validation_error def _get_unique_deployment_name(prefix): return prefix + random_string(16) -def _build_ase_deployment_properties(name, location, subnet_id, virtual_ip_type, - front_end_scale_factor=None, front_end_sku=None, tags=None): +def _build_ase_deployment_properties(name, location, subnet_id, virtual_ip_type=None, + front_end_scale_factor=None, front_end_sku=None, tags=None, kind='ASEv2'): # InternalLoadBalancingMode Enum: None 0, Web 1, Publishing 2. # External: 0 (None), Internal: 3 (Web + Publishing) ilb_mode = 3 if virtual_ip_type == 'Internal' else 0 @@ -362,7 +462,7 @@ def _build_ase_deployment_properties(name, location, subnet_id, virtual_ip_type, 'location': location, 'InternalLoadBalancingMode': ilb_mode, 'virtualNetwork': { - 'Id': subnet_id + 'id': subnet_id } } if front_end_scale_factor: @@ -375,8 +475,8 @@ def _build_ase_deployment_properties(name, location, subnet_id, virtual_ip_type, 'name': name, 'type': 'Microsoft.Web/hostingEnvironments', 'location': location, - 'apiVersion': '2019-02-01', - 'kind': 'ASEV2', + 'apiVersion': '2019-08-01', + 'kind': kind, 'tags': tags, 'properties': ase_properties } @@ -391,6 +491,58 @@ def _build_ase_deployment_properties(name, location, subnet_id, virtual_ip_type, return deployment +def _create_asev2_nsg_rules(cli_ctx, resource_group_name, ase_nsg_name, subnet_address_prefix): + logger.info('Ensure Network Security Group Rules...') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-management', + 100, 'Used to manage ASE from public VIP', '*', 'Allow', 'Inbound', + '*', 'AppServiceManagement', '454-455', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-load-balancer-keep-alive', + 105, 'Allow communication to ASE from Load Balancer', '*', 'Allow', 'Inbound', + '*', 'AzureLoadBalancer', '16001', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'ASE-internal-inbound', + 110, 'ASE-internal-inbound', '*', 'Allow', 'Inbound', + '*', subnet_address_prefix, '*', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-HTTP', + 120, 'Allow HTTP', '*', 'Allow', 'Inbound', + '*', '*', '80', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-HTTPS', + 130, 'Allow HTTPS', '*', 'Allow', 'Inbound', + '*', '*', '443', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-FTP', + 140, 'Allow FTP', '*', 'Allow', 'Inbound', + '*', '*', '21', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-FTPS', + 150, 'Allow FTPS', '*', 'Allow', 'Inbound', + '*', '*', '990', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-FTP-Data', + 160, 'Allow FTP Data', '*', 'Allow', 'Inbound', + '*', '*', '10001-10020', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Inbound-Remote-Debugging', + 170, 'Visual Studio remote debugging', '*', 'Allow', 'Inbound', + '*', '*', '4016-4022', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Outbound-443', + 100, 'Azure Storage blob', '*', 'Allow', 'Outbound', + '*', '*', '443', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Outbound-DB', + 110, 'Database', '*', 'Allow', 'Outbound', + '*', '*', '1433', 'Sql') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Outbound-DNS', + 120, 'DNS', '*', 'Allow', 'Outbound', + '*', '*', '53', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'ASE-internal-outbound', + 130, 'Azure Storage queue', '*', 'Allow', 'Outbound', + '*', '*', '*', subnet_address_prefix) + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Outbound-80', + 140, 'Outbound 80', '*', 'Allow', 'Outbound', + '*', '*', '80', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Outbound-monitor', + 150, 'Azure Monitor', '*', 'Allow', 'Outbound', + '*', '*', '12000', '*') + _create_nsg_rule(cli_ctx, resource_group_name, ase_nsg_name, 'Outbound-NTP', + 160, 'Clock', '*', 'Allow', 'Outbound', + '*', '*', '123', '*') + + def _create_nsg_rule(cli_ctx, resource_group_name, network_security_group_name, security_rule_name, priority, description=None, protocol=None, access=None, direction=None, source_port_range='*', source_address_prefix='*', diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index 1c51f187c8b..94a13ae7341 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -416,6 +416,7 @@ def load_command_table(self, _): g.custom_command('create', 'create_appserviceenvironment_arm', supports_no_wait=True) g.custom_command('update', 'update_appserviceenvironment', supports_no_wait=True) g.custom_command('delete', 'delete_appserviceenvironment', supports_no_wait=True, confirmation=True) + g.custom_command('create-inbound-services', 'create_asev3_inbound_services', is_preview=True) with self.command_group('appservice domain', custom_command_type=appservice_domains, is_preview=True) as g: g.custom_command('create', 'create_domain') 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 c2b42e5801a..edc156da3b7 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -47,7 +47,8 @@ from azure.cli.core.util import get_az_user_agent, send_raw_request from azure.cli.core.profiles import ResourceType, get_sdk from azure.cli.core.azclierror import (ResourceNotFoundError, RequiredArgumentMissingError, ValidationError, - CLIInternalError, UnclassifiedUserFault, AzureResponseError) + CLIInternalError, UnclassifiedUserFault, AzureResponseError, + ArgumentUsageError, MutuallyExclusiveArgumentError) from .tunnel import TunnelServer @@ -1696,23 +1697,24 @@ def create_app_service_plan(cmd, resource_group_name, name, is_linux, hyper_v, p sku = _normalize_sku(sku) _validate_asp_sku(app_service_environment, sku) if is_linux and hyper_v: - raise CLIError('usage error: --is-linux | --hyper-v') + raise MutuallyExclusiveArgumentError('Usage error: --is-linux and --hyper-v cannot be used together.') client = web_client_factory(cmd.cli_ctx) if app_service_environment: if hyper_v: - raise CLIError('Windows containers is not yet supported in app service environment') + raise ArgumentUsageError('Windows containers is not yet supported in app service environment') ase_list = client.app_service_environments.list() ase_found = False ase = None for ase in ase_list: - if ase.name.lower() == app_service_environment.lower(): + if ase.name.lower() == app_service_environment.lower() or ase.id.lower() == app_service_environment.lower(): ase_def = HostingEnvironmentProfile(id=ase.id) location = ase.location ase_found = True break if not ase_found: - raise CLIError("App service environment '{}' not found in subscription.".format(ase.id)) + err_msg = "App service environment '{}' not found in subscription.".format(app_service_environment) + raise ResourceNotFoundError(err_msg) else: # Non-ASE ase_def = None if location is None: @@ -4086,7 +4088,7 @@ def _validate_app_service_environment_id(cli_ctx, ase, resource_group_name): def _validate_asp_sku(app_service_environment, sku): # Isolated SKU is supported only for ASE - if sku in ['I1', 'I2', 'I3']: + if sku.upper() in ['I1', 'I2', 'I3', 'I1V2', 'I2V2', 'I3V2']: if not app_service_environment: raise CLIError("The pricing tier 'Isolated' is not allowed for this app service plan. Use this link to " "learn more: https://docs.microsoft.com/en-us/azure/app-service/overview-hosting-plans") diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py index 49f4e07e6b9..af03dfae026 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_app_service_environment_commands_thru_mock.py @@ -8,12 +8,12 @@ import unittest import mock -from knack.util import CLIError +from azure.cli.core.azclierror import ValidationError from azure.mgmt.web import WebSiteManagementClient from azure.mgmt.web.models import HostingEnvironmentProfile -from azure.mgmt.network.models import (Subnet, RouteTable, Route, NetworkSecurityGroup, SecurityRule) +from azure.mgmt.network.models import (Subnet, RouteTable, Route, NetworkSecurityGroup, SecurityRule, Delegation) from azure.cli.core.adal_authentication import AdalAuthentication from azure.cli.command_modules.appservice.appservice_environment import (show_appserviceenvironment, @@ -103,15 +103,17 @@ def test_app_service_environment_create(self, ase_client_factory_mock, network_c subnet = Subnet(id=1, address_prefix='10.10.10.10/25') network_client.subnets.get.return_value = subnet - # assert that CLIError raised when called with small subnet - with self.assertRaises(CLIError): - create_appserviceenvironment_arm(self.mock_cmd, rg_name, ase_name, subnet_name, vnet_name, + # assert that ValidationError raised when called with small subnet + with self.assertRaises(ValidationError): + create_appserviceenvironment_arm(self.mock_cmd, resource_group_name=rg_name, name=ase_name, + subnet=subnet_name, vnet_name=vnet_name, ignore_network_security_group=True, ignore_route_table=True, location='westeurope') subnet = Subnet(id=1, address_prefix='10.10.10.10/24') network_client.subnets.get.return_value = subnet - create_appserviceenvironment_arm(self.mock_cmd, rg_name, ase_name, subnet_name, vnet_name, + create_appserviceenvironment_arm(self.mock_cmd, resource_group_name=rg_name, name=ase_name, + subnet=subnet_name, vnet_name=vnet_name, ignore_network_security_group=True, ignore_route_table=True, location='westeurope') @@ -133,6 +135,7 @@ def test_app_service_environment_update(self, ase_client_factory_mock, resource_ host_env = HostingEnvironmentProfile(id='id1') host_env.name = ase_name + host_env.kind = 'ASEv2' host_env.resource_group = rg_name host_env.worker_pools = [] ase_client.get.return_value = host_env @@ -143,11 +146,12 @@ def test_app_service_environment_update(self, ase_client_factory_mock, resource_ # Assert create_or_update is called with correct properties assert_host_env = HostingEnvironmentProfile(id='id1') assert_host_env.name = ase_name + assert_host_env.kind = 'ASEv2' assert_host_env.resource_group = rg_name assert_host_env.worker_pools = [] assert_host_env.internal_load_balancing_mode = None assert_host_env.front_end_scale_factor = 10 - ase_client.create_or_update.assert_called_once_with(name=ase_name, resource_group_name=rg_name, + ase_client.create_or_update.assert_called_once_with(resource_group_name=rg_name, name=ase_name, hosting_environment_envelope=assert_host_env) @mock.patch('azure.cli.command_modules.appservice.appservice_environment._get_location_from_resource_group', autospec=True) @@ -176,6 +180,42 @@ def test_app_service_environment_delete(self, ase_client_factory_mock, resource_ assert_host_env.resource_group = rg_name ase_client.delete.assert_called_once_with(name=ase_name, resource_group_name=rg_name) + @mock.patch('azure.cli.command_modules.appservice.appservice_environment._get_unique_deployment_name', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.appservice_environment._get_resource_client_factory', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.appservice_environment._get_network_client_factory', autospec=True) + @mock.patch('azure.cli.command_modules.appservice.appservice_environment._get_ase_client_factory', autospec=True) + def test_app_service_environment_v3_create(self, ase_client_factory_mock, network_client_factory_mock, + resource_client_factory_mock, deployment_name_mock): + ase_name = 'mock_ase_name' + rg_name = 'mock_rg_name' + vnet_name = 'mock_vnet_name' + subnet_name = 'mock_subnet_name' + deployment_name = 'mock_deployment_name' + + ase_client = mock.MagicMock() + ase_client_factory_mock.return_value = ase_client + + resource_client_mock = mock.MagicMock() + resource_client_factory_mock.return_value = resource_client_mock + + deployment_name_mock.return_value = deployment_name + + network_client = mock.MagicMock() + network_client_factory_mock.return_value = network_client + + subnet = Subnet(id=1, address_prefix='10.10.10.10/24') + hosting_delegation = Delegation(id=1, service_name='Microsoft.Web/hostingEnvironments') + subnet.delegations = [hosting_delegation] + network_client.subnets.get.return_value = subnet + create_appserviceenvironment_arm(self.mock_cmd, resource_group_name=rg_name, name=ase_name, + subnet=subnet_name, vnet_name=vnet_name, kind='ASEv3', + location='westeurope') + + # Assert create_or_update is called with correct rg and deployment name + resource_client_mock.deployments.create_or_update.assert_called_once() + self.assertEqual(resource_client_mock.deployments.create_or_update.call_args[0][0], rg_name) + self.assertEqual(resource_client_mock.deployments.create_or_update.call_args[0][1], deployment_name) + if __name__ == '__main__': unittest.main() diff --git a/src/azure-cli/azure/cli/command_modules/appservice/utils.py b/src/azure-cli/azure/cli/command_modules/appservice/utils.py index 33c5840dc1e..5fc8eceeca1 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/utils.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/utils.py @@ -48,6 +48,8 @@ def get_sku_name(tier): # pylint: disable=too-many-return-statements return 'ElasticPremium' if tier in ['I1', 'I2', 'I3']: return 'Isolated' + if tier in ['I1V2', 'I2V2', 'I3V2']: + return 'IsolatedV2' raise CLIError("Invalid sku(pricing tier), please refer to command help for valid values")