diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8bf6703ca9e..8ee9a258445 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -136,6 +136,8 @@ /src/kusto/ @ilayr @orhasban @astauben +/src/appservice-kube/ @ebencarek @calcha @StrawnSC + /src/custom-providers/ @jsntcy /src/costmanagement/ @kairu-ms @jsntcy diff --git a/src/appservice-kube/HISTORY.rst b/src/appservice-kube/HISTORY.rst new file mode 100644 index 00000000000..3b56fc6da4d --- /dev/null +++ b/src/appservice-kube/HISTORY.rst @@ -0,0 +1,9 @@ +.. :changelog: + +Release History +=============== + + +0.1.0 +++++++ +* Initial public preview release. diff --git a/src/appservice-kube/README.rst b/src/appservice-kube/README.rst new file mode 100644 index 00000000000..f652fc6599f --- /dev/null +++ b/src/appservice-kube/README.rst @@ -0,0 +1,5 @@ +Microsoft Azure CLI 'appservice-kube' Extension +========================================== + +The appservice-kube extension adds support for controlling App Service Kubernetes Environments. +See here for more information: https://docs.microsoft.com/en-us/azure/app-service/manage-create-arc-environment?tabs=bash \ No newline at end of file diff --git a/src/appservice-kube/azext_appservice_kube/__init__.py b/src/appservice-kube/azext_appservice_kube/__init__.py new file mode 100644 index 00000000000..a8a284c89f4 --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/__init__.py @@ -0,0 +1,33 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core import AzCommandsLoader + +from azext_appservice_kube._help import helps # pylint: disable=unused-import + + +class AppserviceCommandsLoader(AzCommandsLoader): + + def __init__(self, cli_ctx=None): + from azure.cli.core.commands import CliCommandType + from azure.cli.core.profiles import ResourceType + appservice_custom = CliCommandType(operations_tmpl='azext_appservice_kube.custom#{}') + super().__init__(cli_ctx=cli_ctx, + custom_command_type=appservice_custom, + resource_type=ResourceType.MGMT_APPSERVICE) + + def load_command_table(self, args): + super().load_command_table(args) + from azext_appservice_kube.commands import load_command_table + load_command_table(self, args) + return self.command_table + + def load_arguments(self, command): + super().load_arguments(command) + from azext_appservice_kube._params import load_arguments + load_arguments(self, command) + + +COMMAND_LOADER_CLS = AppserviceCommandsLoader diff --git a/src/appservice-kube/azext_appservice_kube/_appservice_utils.py b/src/appservice-kube/azext_appservice_kube/_appservice_utils.py new file mode 100644 index 00000000000..2e6b646ceda --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/_appservice_utils.py @@ -0,0 +1,21 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from ._client_factory import web_client_factory + + +def _generic_site_operation(cli_ctx, resource_group_name, name, operation_name, slot=None, + extra_parameter=None, client=None): + client = client or web_client_factory(cli_ctx) + operation = getattr(client.web_apps, + operation_name if slot is None else operation_name + '_slot') + if slot is None: + return (operation(resource_group_name, name) + if extra_parameter is None else operation(resource_group_name, + name, extra_parameter)) + + return (operation(resource_group_name, name, slot) + if extra_parameter is None else operation(resource_group_name, + name, extra_parameter, slot)) diff --git a/src/appservice-kube/azext_appservice_kube/_client_factory.py b/src/appservice-kube/azext_appservice_kube/_client_factory.py new file mode 100644 index 00000000000..3f73cd71620 --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/_client_factory.py @@ -0,0 +1,54 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.commands.client_factory import get_mgmt_service_client +from azure.cli.command_modules.appservice._client_factory import web_client_factory +from azure.cli.core.profiles import ResourceType + + +# pylint: disable=inconsistent-return-statements +def ex_handler_factory(creating_plan=False, no_throw=False): + def _polish_bad_errors(ex): + import json + from azure.cli.core.azclierror import ValidationError + try: + detail = json.loads(ex.response.text)['Message'] + if creating_plan: + if 'Requested features are not supported in region' in detail: + detail = ("Plan with linux worker is not supported in current region. For " + + "supported regions, please refer to https://docs.microsoft.com/" + "azure/app-service-web/app-service-linux-intro") + elif 'Not enough available reserved instance servers to satisfy' in detail: + detail = ("Plan with Linux worker can only be created in a group " + + "which has never contained a Windows worker, and vice versa. " + + "Please use a new resource group. Original error:" + detail) + ex = ValidationError(detail) + except Exception: # pylint: disable=broad-except + pass + if no_throw: + return ex + raise ex + return _polish_bad_errors + + +def customlocation_client_factory(cli_ctx, **_): + return get_mgmt_service_client(cli_ctx, ResourceType.MGMT_CUSTOMLOCATION) + + +def resource_client_factory(cli_ctx, **_): + return get_mgmt_service_client(cli_ctx, ResourceType.MGMT_RESOURCE_RESOURCES) + + +def cf_plans(cli_ctx, *_): + return web_client_factory(cli_ctx).app_service_plans + + +def cf_compute_service(cli_ctx, *_): + return get_mgmt_service_client(cli_ctx, ResourceType.MGMT_COMPUTE) + + +def cf_resource_groups(cli_ctx, subscription_id=None): + return get_mgmt_service_client(cli_ctx, ResourceType.MGMT_RESOURCE_RESOURCES, + subscription_id=subscription_id).resource_groups diff --git a/src/appservice-kube/azext_appservice_kube/_completers.py b/src/appservice-kube/azext_appservice_kube/_completers.py new file mode 100644 index 00000000000..b8676309fea --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/_completers.py @@ -0,0 +1,53 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.decorators import Completer +from ._utils import _get_location_from_resource_group +from ._constants import KUBE_DEFAULT_SKU + + +@Completer +def get_vm_size_completion_list(cmd, prefix, namespace): # pylint: disable=unused-argument + """Return the intersection of the VM sizes allowed by the ACS SDK with those returned by the Compute Service.""" + from azure.mgmt.containerservice.models import ContainerServiceVMSizeTypes + + location = _get_location(cmd.cli_ctx, namespace) + result = get_vm_sizes(cmd.cli_ctx, location) + return set(r.name for r in result) & set(c.value for c in ContainerServiceVMSizeTypes) + + +@Completer +def get_kube_sku_completion_list(cmd, prefix, namespace): # pylint: disable=unused-argument + """ + Return the VM sizes allowed by AKS, or 'ANY' + """ + return get_vm_size_completion_list(cmd, prefix, namespace) & set(KUBE_DEFAULT_SKU) + + +def get_vm_sizes(cli_ctx, location): + from ._client_factory import cf_compute_service + return cf_compute_service(cli_ctx).virtual_machine_sizes.list(location) + + +def _get_location(cli_ctx, namespace): + """ + Return an Azure location by using an explicit `--location` argument, then by `--resource-group`, and + finally by the subscription if neither argument was provided. + """ + from azure.core.exceptions import HttpResponseError + from azure.cli.core.commands.parameters import get_one_of_subscription_locations + + location = None + if getattr(namespace, 'location', None): + location = namespace.location + elif getattr(namespace, 'resource_group_name', None): + try: + location = _get_location_from_resource_group(cli_ctx, namespace.resource_group_name) + except HttpResponseError as err: + from argcomplete import warn + warn('Warning: {}'.format(err.message)) + if not location: + location = get_one_of_subscription_locations(cli_ctx) + return location diff --git a/src/appservice-kube/azext_appservice_kube/_constants.py b/src/appservice-kube/azext_appservice_kube/_constants.py new file mode 100644 index 00000000000..6bfabf7ca0c --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/_constants.py @@ -0,0 +1,84 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +KUBE_DEFAULT_SKU = "K1" +KUBE_ASP_KIND = "linux,kubernetes" +KUBE_APP_KIND = "linux,kubernetes,app" +KUBE_CONTAINER_APP_KIND = 'linux,kubernetes,app,container' +KUBE_FUNCTION_APP_KIND = 'linux,kubernetes,functionapp' +KUBE_FUNCTION_CONTAINER_APP_KIND = 'linux,kubernetes,functionapp,container' + +LINUX_RUNTIMES = ['dotnet', 'node', 'python', 'java'] +WINDOWS_RUNTIMES = ['dotnet', 'node', 'java', 'powershell'] + +NODE_VERSION_DEFAULT = "10.14" +NODE_VERSION_NEWER = "12-lts" +NODE_EXACT_VERSION_DEFAULT = "10.14.1" +NETCORE_VERSION_DEFAULT = "2.2" +DOTNET_VERSION_DEFAULT = "4.7" +PYTHON_VERSION_DEFAULT = "3.7" +NETCORE_RUNTIME_NAME = "dotnetcore" +DOTNET_RUNTIME_NAME = "aspnet" +NODE_RUNTIME_NAME = "node" +PYTHON_RUNTIME_NAME = "python" +OS_DEFAULT = "Windows" +STATIC_RUNTIME_NAME = "static" # not an official supported runtime but used for CLI logic +NODE_VERSIONS = ['4.4', '4.5', '6.2', '6.6', '6.9', '6.11', '8.0', '8.1', '8.9', '8.11', '10.1', '10.10', '10.14'] +PYTHON_VERSIONS = ['3.7', '3.6', '2.7'] +NETCORE_VERSIONS = ['1.0', '1.1', '2.1', '2.2'] +DOTNET_VERSIONS = ['3.5', '4.7'] + +LINUX_SKU_DEFAULT = "P1V2" +FUNCTIONS_VERSIONS = ['2', '3'] + +# functions version : default node version +FUNCTIONS_VERSION_TO_DEFAULT_NODE_VERSION = { + '2': '~10', + '3': '~12' +} +# functions version -> runtime : default runtime version +FUNCTIONS_VERSION_TO_DEFAULT_RUNTIME_VERSION = { + '2': { + 'node': '8', + 'dotnet': '2', + 'python': '3.7', + 'java': '8' + }, + '3': { + 'node': '12', + 'dotnet': '3', + 'python': '3.7', + 'java': '8' + } +} +# functions version -> runtime : runtime versions +FUNCTIONS_VERSION_TO_SUPPORTED_RUNTIME_VERSIONS = { + '2': { + 'node': ['8', '10'], + 'python': ['3.6', '3.7'], + 'dotnet': ['2'], + 'java': ['8'] + }, + '3': { + 'node': ['10', '12'], + 'python': ['3.6', '3.7', '3.8'], + 'dotnet': ['3'], + 'java': ['8'] + } +} +# dotnet runtime version : dotnet linuxFxVersion +DOTNET_RUNTIME_VERSION_TO_DOTNET_LINUX_FX_VERSION = { + '2': '2.2', + '3': '3.1' +} + +MULTI_CONTAINER_TYPES = ['COMPOSE', 'KUBE'] + +OS_TYPES = ['Windows', 'Linux'] + +CONTAINER_APPSETTING_NAMES = ['DOCKER_REGISTRY_SERVER_URL', 'DOCKER_REGISTRY_SERVER_USERNAME', + 'DOCKER_REGISTRY_SERVER_PASSWORD', "WEBSITES_ENABLE_APP_SERVICE_STORAGE"] +APPSETTINGS_TO_MASK = ['DOCKER_REGISTRY_SERVER_PASSWORD'] diff --git a/src/appservice-kube/azext_appservice_kube/_create_util.py b/src/appservice-kube/azext_appservice_kube/_create_util.py new file mode 100644 index 00000000000..740512fd9fa --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/_create_util.py @@ -0,0 +1,431 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import zipfile +from knack.log import get_logger + +from azure.mgmt.web.models import SkuDescription +from azure.cli.core.azclierror import ValidationError, ArgumentUsageError + +from ._constants import (NETCORE_VERSION_DEFAULT, NETCORE_VERSIONS, NODE_VERSION_DEFAULT, + NODE_VERSIONS, NETCORE_RUNTIME_NAME, NODE_RUNTIME_NAME, DOTNET_RUNTIME_NAME, + DOTNET_VERSION_DEFAULT, DOTNET_VERSIONS, STATIC_RUNTIME_NAME, + PYTHON_RUNTIME_NAME, PYTHON_VERSION_DEFAULT, LINUX_SKU_DEFAULT, OS_DEFAULT) +from ._client_factory import web_client_factory, resource_client_factory +logger = get_logger(__name__) + + +def get_runtime_version_details(file_path, lang_name, is_kube=False): + version_detected = None + version_to_create = None + if lang_name.lower() == NETCORE_RUNTIME_NAME: + # method returns list in DESC, pick the first + version_detected = parse_netcore_version(file_path)[0] + version_to_create = detect_netcore_version_tocreate(version_detected) + elif lang_name.lower() == DOTNET_RUNTIME_NAME: + # method returns list in DESC, pick the first + version_detected = parse_dotnet_version(file_path) + version_to_create = detect_dotnet_version_tocreate(version_detected) + if is_kube: + raise ValidationError("Dotnet runtime is not supported for Kube Environments") + elif lang_name.lower() == NODE_RUNTIME_NAME: + if file_path == '': + version_detected = "-" + version_to_create = NODE_VERSION_DEFAULT + else: + version_detected = parse_node_version(file_path)[0] + version_to_create = detect_node_version_tocreate(version_detected) + elif lang_name.lower() == PYTHON_RUNTIME_NAME: + version_detected = "-" + version_to_create = PYTHON_VERSION_DEFAULT + elif lang_name.lower() == STATIC_RUNTIME_NAME: + version_detected = "-" + version_to_create = "-" + return {'detected': version_detected, 'to_create': version_to_create} + + +def zip_contents_from_dir(dirPath, lang, is_kube=None): + relroot = os.path.abspath(os.path.join(dirPath, os.pardir)) + + path_and_file = os.path.splitdrive(dirPath)[1] + file_val = os.path.split(path_and_file)[1] + zip_file_path = relroot + os.path.sep + file_val + ".zip" + abs_src = os.path.abspath(dirPath) + with zipfile.ZipFile("{}".format(zip_file_path), "w", zipfile.ZIP_DEFLATED) as zf: + for dirname, subdirs, files in os.walk(dirPath): + # skip node_modules folder for Node apps, + # since zip_deployment will perform the build operation + # TODO: Add .deployment support in Kube BuildSVC and remove this check + if not is_kube: + if lang.lower() == NODE_RUNTIME_NAME: + subdirs[:] = [d for d in subdirs if 'node_modules' not in d] + elif lang.lower() == NETCORE_RUNTIME_NAME: + subdirs[:] = [d for d in subdirs if d not in ['obj', 'bin']] + elif lang.lower() == PYTHON_RUNTIME_NAME: + subdirs[:] = [d for d in subdirs if 'env' not in d] # Ignores dir that contain env + for filename in files: + absname = os.path.abspath(os.path.join(dirname, filename)) + arcname = absname[len(abs_src) + 1:] + zf.write(absname, arcname) + return zip_file_path + + +def create_resource_group(cmd, rg_name, location): + from azure.cli.core.profiles import ResourceType, get_sdk + rcf = resource_client_factory(cmd.cli_ctx) + resource_group = get_sdk(cmd.cli_ctx, ResourceType.MGMT_RESOURCE_RESOURCES, 'ResourceGroup', mod='models') + rg_params = resource_group(location=location) + return rcf.resource_groups.create_or_update(rg_name, rg_params) + + +def _check_resource_group_exists(cmd, rg_name): + rcf = resource_client_factory(cmd.cli_ctx) + return rcf.resource_groups.check_existence(rg_name) + + +def _check_resource_group_supports_os(cmd, rg_name, is_linux): + # get all appservice plans from RG + client = web_client_factory(cmd.cli_ctx) + plans = list(client.app_service_plans.list_by_resource_group(rg_name)) + for item in plans: + # for Linux if an app with reserved==False exists, ASP doesn't support Linux + if is_linux and not item.reserved: + return False + if not is_linux and item.reserved: + return False + return True + + +def get_num_apps_in_asp(cmd, rg_name, asp_name): + client = web_client_factory(cmd.cli_ctx) + return len(list(client.app_service_plans.list_web_apps(rg_name, asp_name))) + + +# pylint:disable=unexpected-keyword-arg +def get_lang_from_content(src_path, html=False): + # NODE: package.json should exist in the application root dir + # NETCORE & DOTNET: *.csproj should exist in the application dir + # NETCORE: netcoreapp2.0 + # DOTNET: v4.5.2 + runtime_details_dict = dict.fromkeys(['language', 'file_loc', 'default_sku']) + package_json_file = os.path.join(src_path, 'package.json') + package_python_file = os.path.join(src_path, 'requirements.txt') + static_html_file = "" + package_netcore_file = "" + runtime_details_dict['language'] = '' + runtime_details_dict['file_loc'] = '' + runtime_details_dict['default_sku'] = 'F1' + import fnmatch + for _dirpath, _dirnames, files in os.walk(src_path): + for file in files: + if html and (fnmatch.fnmatch(file, "*.html") or fnmatch.fnmatch(file, "*.htm") or + fnmatch.fnmatch(file, "*shtml.")): + static_html_file = os.path.join(src_path, file) + break + if fnmatch.fnmatch(file, "*.csproj"): + package_netcore_file = os.path.join(src_path, file) + break + + if html: + if static_html_file: + runtime_details_dict['language'] = STATIC_RUNTIME_NAME + runtime_details_dict['file_loc'] = static_html_file + runtime_details_dict['default_sku'] = 'F1' + else: + raise ArgumentUsageError("The html flag was passed, but could not find HTML files, " + "see 'https://go.microsoft.com/fwlink/?linkid=2109470' for more information") + elif os.path.isfile(package_python_file): + runtime_details_dict['language'] = PYTHON_RUNTIME_NAME + runtime_details_dict['file_loc'] = package_python_file + runtime_details_dict['default_sku'] = LINUX_SKU_DEFAULT + elif os.path.isfile(package_json_file) or os.path.isfile('server.js') or os.path.isfile('index.js'): + runtime_details_dict['language'] = NODE_RUNTIME_NAME + runtime_details_dict['file_loc'] = package_json_file if os.path.isfile(package_json_file) else '' + runtime_details_dict['default_sku'] = LINUX_SKU_DEFAULT + elif package_netcore_file: + runtime_lang = detect_dotnet_lang(package_netcore_file) + runtime_details_dict['language'] = runtime_lang + runtime_details_dict['file_loc'] = package_netcore_file + runtime_details_dict['default_sku'] = 'F1' + else: # TODO: Update the doc when the detection logic gets updated + raise ValidationError("Could not auto-detect the runtime stack of your app, " + "see 'https://go.microsoft.com/fwlink/?linkid=2109470' for more information") + return runtime_details_dict + + +def detect_dotnet_lang(csproj_path): + import xml.etree.ElementTree as ET + import re + parsed_file = ET.parse(csproj_path) + root = parsed_file.getroot() + version_lang = '' + for target_ver in root.iter('TargetFramework'): + version_lang = re.sub(r'([^a-zA-Z\s]+?)', '', target_ver.text) + if 'netcore' in version_lang.lower(): + return NETCORE_RUNTIME_NAME + return DOTNET_RUNTIME_NAME + + +def parse_dotnet_version(file_path): + version_detected = ['4.7'] + try: + from xml.dom import minidom + import re + xmldoc = minidom.parse(file_path) + framework_ver = xmldoc.getElementsByTagName('TargetFrameworkVersion') + target_ver = framework_ver[0].firstChild.data + non_decimal = re.compile(r'[^\d.]+') + # reduce the version to '5.7.4' from '5.7' + if target_ver is not None: + # remove the string from the beginning of the version value + c = non_decimal.sub('', target_ver) + version_detected = c[:3] + except: # pylint: disable=bare-except + version_detected = version_detected[0] + return version_detected + + +def parse_netcore_version(file_path): + import xml.etree.ElementTree as ET + import re + version_detected = ['0.0'] + parsed_file = ET.parse(file_path) + root = parsed_file.getroot() + for target_ver in root.iter('TargetFramework'): + version_detected = re.findall(r"\d+\.\d+", target_ver.text) + # incase of multiple versions detected, return list in descending order + version_detected = sorted(version_detected, key=float, reverse=True) + return version_detected + + +def parse_node_version(file_path): + # from node experts the node value in package.json can be found here "engines": { "node": ">=10.6.0"} + import json + import re + version_detected = [] + with open(file_path) as data_file: + data = json.load(data_file) + for key, value in data.items(): + if key == 'engines' and 'node' in value: + value_detected = value['node'] + non_decimal = re.compile(r'[^\d.]+') + # remove the string ~ or > that sometimes exists in version value + c = non_decimal.sub('', value_detected) + # reduce the version to '6.0' from '6.0.0' + if '.' in c: # handle version set as 4 instead of 4.0 + num_array = c.split('.') + num = num_array[0] + "." + num_array[1] + else: + num = c + ".0" + version_detected.append(num) + return version_detected or ['0.0'] + + +def detect_netcore_version_tocreate(detected_ver): + if detected_ver in NETCORE_VERSIONS: + return detected_ver + return NETCORE_VERSION_DEFAULT + + +def detect_dotnet_version_tocreate(detected_ver): + min_ver = DOTNET_VERSIONS[0] + if detected_ver in DOTNET_VERSIONS: + return detected_ver + if detected_ver < min_ver: + return min_ver + return DOTNET_VERSION_DEFAULT + + +def detect_node_version_tocreate(detected_ver): + if detected_ver in NODE_VERSIONS: + return detected_ver + # get major version & get the closest version from supported list + major_ver = int(detected_ver.split('.')[0]) + node_ver = NODE_VERSION_DEFAULT + if major_ver < 4: + node_ver = NODE_VERSION_DEFAULT + elif 4 <= major_ver < 6: + node_ver = '4.5' + elif 6 <= major_ver < 8: + node_ver = '6.9' + elif 8 <= major_ver < 10: + node_ver = NODE_VERSION_DEFAULT + elif major_ver >= 10: + node_ver = '10.14' + return node_ver + + +def find_key_in_json(json_data, key): + for k, v in json_data.items(): + if key in k: + yield v + elif isinstance(v, dict): + for id_val in find_key_in_json(v, key): + yield id_val + + +def set_location(cmd, sku, location): + client = web_client_factory(cmd.cli_ctx) + if location is None: + locs = client.list_geo_regions(sku, True) + available_locs = [] + for loc in locs: + available_locs.append(loc.name) + loc = available_locs[0] + else: + loc = location + return loc.replace(" ", "").lower() + + +# check if the RG value to use already exists and follows the OS requirements or new RG to be created +def should_create_new_rg(cmd, rg_name, is_linux): + if (_check_resource_group_exists(cmd, rg_name) and + _check_resource_group_supports_os(cmd, rg_name, is_linux)): + return False + return True + + +def get_site_availability(cmd, name): + """ This is used by az webapp up to verify if a site needs to be created or should just be deployed""" + client = web_client_factory(cmd.cli_ctx) + availability = client.check_name_availability(name, 'Site') + + # check for "." in app name. it is valid for hostnames to contain it, but not allowed for webapp names + if "." in name: + availability.name_available = False + availability.reason = "Invalid" + availability.message = ("Site names only allow alphanumeric characters and hyphens, " + "cannot start or end in a hyphen, and must be less than 64 chars.") + return availability + + +def does_app_already_exist(cmd, name): + """ This is used by az webapp up to verify if a site needs to be created or should just be deployed""" + client = web_client_factory(cmd.cli_ctx) + site_availability = client.check_name_availability(name, 'Microsoft.Web/sites') + # check availability returns true to name_available == site does not exist + return site_availability.name_available + + +def get_app_details(cmd, name, resource_group): + client = web_client_factory(cmd.cli_ctx) + return client.web_apps.get(resource_group, name) + # ''' + # data = (list(filter(lambda x: name.lower() in x.name.lower(), client.web_apps.list()))) + # _num_items = len(data) + # if _num_items > 0: + # return data[0] + # return None + # ''' + + +def get_rg_to_use(cmd, user, loc, os_name, rg_name=None): + default_rg = "{}_rg_{}_{}".format(user, os_name, loc.replace(" ", "").lower()) + # check if RG exists & can be used + if rg_name is not None and _check_resource_group_exists(cmd, rg_name): + if _check_resource_group_supports_os(cmd, rg_name, os_name.lower() == 'linux'): + return rg_name + raise ArgumentUsageError("The ResourceGroup '{}' cannot be used with the os '{}'. " + "Use a different RG".format(rg_name, os_name)) + if rg_name is None: + rg_name = default_rg + return rg_name + + +def get_profile_username(): + from azure.cli.core._profile import Profile + user = Profile().get_current_account_user() + user = user.split('@', 1)[0] + if len(user.split('#', 1)) > 1: # on cloudShell user is in format live.com#user@domain.com + user = user.split('#', 1)[1] + return user + + +def get_sku_to_use(src_dir, html=False, sku=None): + if sku is None: + lang_details = get_lang_from_content(src_dir, html) + return lang_details.get("default_sku") + logger.info("Found sku argument, skipping use default sku") + return sku + + +def set_language(src_dir, html=False): + lang_details = get_lang_from_content(src_dir, html) + return lang_details.get('language') + + +def detect_os_form_src(src_dir, html=False): + lang_details = get_lang_from_content(src_dir, html) + language = lang_details.get('language') + return "Linux" if language is not None and language.lower() == NODE_RUNTIME_NAME \ + or language.lower() == PYTHON_RUNTIME_NAME else OS_DEFAULT + + +def get_plan_to_use(cmd, user, os_name, loc, sku, create_rg, resource_group_name, plan=None): + _default_asp = "{}_asp_{}_{}_0".format(user, os_name, loc) + if plan is None: # --plan not provided by user + # get the plan name to use + return _determine_if_default_plan_to_use(cmd, _default_asp, resource_group_name, loc, sku, create_rg) + return plan + + +def get_kube_plan_to_use(cmd, kube_environment, loc, sku, create_rg, resource_group_name, plan=None): + _default_asp = "{}_asp_{}_{}_0".format(kube_environment, sku, resource_group_name) + if plan is None: # --plan not provided by user + # get the plan name to use + return _determine_if_default_plan_to_use(cmd, _default_asp, resource_group_name, loc, sku, create_rg) + return plan + + +# Portal uses the current_stack property in the app metadata to display the correct stack +# This value should be one of: ['dotnet', 'dotnetcore', 'node', 'php', 'python', 'java'] +def get_current_stack_from_runtime(runtime): + language = runtime.split('|')[0].lower() + if language == 'aspnet': + return 'dotnet' + return language + + +# if plan name not provided we need to get a plan name based on the OS, location & SKU +def _determine_if_default_plan_to_use(cmd, plan_name, resource_group_name, loc, sku, create_rg): + client = web_client_factory(cmd.cli_ctx) + if create_rg: # if new RG needs to be created use the default name + return plan_name + # get all ASPs in the RG & filter to the ones that contain the plan_name + _asp_generic = plan_name[:-len(plan_name.split("_")[4])] + _asp_list = (list(filter(lambda x: _asp_generic in x.name, + client.app_service_plans.list_by_resource_group(resource_group_name)))) + _num_asp = len(_asp_list) + if _num_asp: + # check if we have at least one app that can be used with the combination of loc, sku & os + selected_asp = next((a for a in _asp_list if isinstance(a.sku, SkuDescription) and + a.sku.name.lower() == sku.lower() and + (a.location.replace(" ", "").lower() == loc.lower())), None) + if selected_asp is not None: + return selected_asp.name + # from the sorted data pick the last one & check if a new ASP needs to be created + # based on SKU or not + data_sorted = sorted(_asp_list, key=lambda x: x.name) + _plan_info = data_sorted[_num_asp - 1] + _asp_num = int(_plan_info.name.split('_')[4]) + 1 # default asp created by CLI can be of type plan_num + return '{}_{}'.format(_asp_generic, _asp_num) + return plan_name + + +def should_create_new_app(cmd, rg_name, app_name): # this is currently referenced by an extension command + client = web_client_factory(cmd.cli_ctx) + for item in list(client.web_apps.list_by_resource_group(rg_name)): + if item.name.lower() == app_name.lower(): + return False + return True + + +def generate_default_app_service_plan_name(webapp_name): + import uuid + random_uuid = str(uuid.uuid4().hex) + webapp_name = webapp_name[:222] # max length for app service plan name is 260 + + return '{}_plan_{}'.format(webapp_name, random_uuid) diff --git a/src/appservice-kube/azext_appservice_kube/_help.py b/src/appservice-kube/azext_appservice_kube/_help.py new file mode 100644 index 00000000000..3fa301c209d --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/_help.py @@ -0,0 +1,114 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.help_files import helps +# pylint: disable=line-too-long, too-many-lines + +helps['webapp scale'] = """ +type: command +short-summary: Modify the number of instances of a webapp. +examples: + - name: Change the number of instances of MyApp to 2. + text: > + az webapp scale -g MyResourceGroup -n MyApp --instance-count 2 +""" + +helps['appservice plan create'] = """ +type: command +short-summary: Create an app service plan. +examples: + - name: Create a basic app service plan. + text: > + az appservice plan create -g MyResourceGroup -n MyPlan + - name: Create a standard app service plan with with four Linux workers. + text: > + az appservice plan create -g MyResourceGroup -n MyPlan \\ + --is-linux --number-of-workers 4 --sku S1 + - name: Create an app service plan for app service environment. + text: > + az appservice plan create -g MyResourceGroup -n MyPlan \\ + --app-service-environment MyAppServiceEnvironment --sku I1 + - name: Create an app service plan for a kubernetes environment. + text: > + az appservice plan create -g MyResourceGroup -n MyPlan \\ + --custom-location /subscriptions//resourceGroups//providers/Microsoft.ExtendedLocation/customLocations/ \\ + --per-site-scaling --is-linux --sku K1 +""" + +helps['appservice plan update'] = """ +type: command +short-summary: Update an app service plan. See https://docs.microsoft.com/azure/app-service/app-service-plan-manage#move-an-app-to-another-app-service-plan to learn more +examples: + - name: Update an app service plan. (autogenerated) + text: az appservice plan update --name MyAppServicePlan --resource-group MyResourceGroup --sku F1 + crafted: true + - name: Update a kubernetes app service plan. + text: > + az appservice plan update --name MyAppServicePlan --resource-group MyResourceGroup \\ + --sku ANY --number-of-workers 3 +""" + +helps['appservice kube'] = """ + type: group + short-summary: Manage Kubernetes Environments +""" + +helps['appservice kube create'] = """ + type: command + short-summary: Create a Kubernetes Environment. + examples: + - name: Create Kubernetes Environment + text: | + az appservice kube create -n MyKubeEnvironment -g MyResourceGroup --static-ip 0.0.0.0 --custom-location custom_location_id +""" + +helps['appservice kube update'] = """ + type: command + short-summary: Update a Kubernetes Environment. Currently not supported + examples: + - name: Update Kubernetes Environment + text: | + az appservice kube update --name MyKubeEnvironment -g MyResourceGroup --static-ip 0.0.0.0 +""" + +helps['appservice kube show'] = """ + type: command + short-summary: Show the details of a kubernetes environment. + examples: + - name: Show the details of a Kubernetes Environment. + text: | + az appservice kube show -n MyKubeEnvironment -g MyResourceGroup +""" + +helps['appservice kube list'] = """ + type: command + short-summary: List kubernetes environments by subscription or resource group. + examples: + - name: List Kubernetes Environments by subscription. + text: | + az appservice kube list + - name: List Kubernetes Environments by resource group. + text: | + az appservice kube list -g MyResourceGroup +""" + +helps['appservice kube delete'] = """ + type: command + short-summary: Delete kubernetes environment. + examples: + - name: Delete Kubernetes Environment. + text: az appservice kube delete -g MyResourceGroup -n MyKubeEnvironment +""" + +helps['appservice kube wait'] = """ + type: command + short-summary: Wait for a Kubernetes Environment to reach a desired state. + examples: + - name: Wait for a Kubernetes Environment to be provisioned, polling every 60 seconds. + text: | + az appservice kube wait -g MyResourceGroup -n MyKubeEnvironment \\ + --created --interval 60 +""" diff --git a/src/appservice-kube/azext_appservice_kube/_params.py b/src/appservice-kube/azext_appservice_kube/_params.py new file mode 100644 index 00000000000..072c46d9dea --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/_params.py @@ -0,0 +1,194 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +# pylint: disable=line-too-long + +from knack.arguments import CLIArgumentType + +from azure.cli.command_modules.appservice._validators import (validate_site_create) +from azure.cli.core.commands.parameters import (resource_group_name_type, get_location_type, + get_resource_name_completion_list, + get_three_state_flag, get_enum_type, tags_type) + +from ._constants import (FUNCTIONS_VERSIONS, FUNCTIONS_VERSION_TO_SUPPORTED_RUNTIME_VERSIONS, + LINUX_RUNTIMES, WINDOWS_RUNTIMES, MULTI_CONTAINER_TYPES, OS_TYPES) +from ._validators import validate_asp_create, validate_timeout_value + + +def load_arguments(self, _): + # pylint: disable=too-many-statements + # pylint: disable=line-too-long + 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), PC2 (Premium Container Small), PC3 (Premium Container Medium), PC4 (Premium Container Large), I1 (Isolated Small), I2 (Isolated Medium), I3 (Isolated Large), Any, ElasticAny', + arg_type=get_enum_type(['F1', 'FREE', 'D1', 'SHARED', 'B1', 'B2', 'B3', 'S1', 'S2', 'S3', 'P1V2', 'P2V2', 'P3V2', 'PC2', 'PC3', 'PC4', 'I1', 'I2', 'I3', 'ANY', 'ELASTICANY'])) + 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', + help="name of the web app. You can configure the default using `az configure --defaults web=`") + + self.get_models('K8SENetworkPlugin') + + # combine all runtime versions for all functions versions + functionapp_runtime_to_version = {} + for functions_version in FUNCTIONS_VERSION_TO_SUPPORTED_RUNTIME_VERSIONS.values(): + for runtime, val in functions_version.items(): + # dotnet version is not configurable, so leave out of help menu + if runtime != 'dotnet': + functionapp_runtime_to_version[runtime] = functionapp_runtime_to_version.get(runtime, set()).union(val) + + functionapp_runtime_to_version_texts = [] + for runtime, runtime_versions in functionapp_runtime_to_version.items(): + runtime_versions_list = list(runtime_versions) + runtime_versions_list.sort(key=float) + functionapp_runtime_to_version_texts.append(runtime + ' -> [' + ', '.join(runtime_versions_list) + ']') + + with self.argument_context('webapp') as c: + c.ignore('app_instance') + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('location', arg_type=get_location_type(self.cli_ctx)) + c.argument('slot', options_list=['--slot', '-s'], help="the name of the slot. Default to the productions slot if not specified") + c.argument('name', configured_default='web', arg_type=name_arg_type, + completer=get_resource_name_completion_list('Microsoft.Web/sites'), id_part='name', + help="name of the web app. You can configure the default using `az configure --defaults web=`") + + with self.argument_context('webapp create') as c: + c.argument('name', options_list=['--name', '-n'], help='name of the new web app', validator=validate_site_create) + c.argument('custom_location', help="Name or ID of the custom location") + c.argument('startup_file', help="Linux only. The web's startup file") + c.argument('docker_registry_server_user', options_list=['--docker-registry-server-user', '-s'], help='the container registry server username') + c.argument('docker_registry_server_password', options_list=['--docker-registry-server-password', '-w'], help='The container registry server password. Required for private registries.') + c.argument('multicontainer_config_type', options_list=['--multicontainer-config-type'], help="Linux only.", arg_type=get_enum_type(MULTI_CONTAINER_TYPES)) + c.argument('multicontainer_config_file', options_list=['--multicontainer-config-file'], help="Linux only. Config file for multicontainer apps. (local or remote)") + c.argument('runtime', options_list=['--runtime', '-r'], help="canonicalized web runtime in the format of Framework|Version, e.g. \"PHP|5.6\". Use `az webapp list-runtimes` for available list") # TODO ADD completer + c.argument('plan', options_list=['--plan', '-p'], configured_default='appserviceplan', + completer=get_resource_name_completion_list('Microsoft.Web/serverFarms'), + help="name or resource id of the app service plan. Use 'appservice plan create' to get one") + c.ignore('language') + c.ignore('using_webapp_up') + + with self.argument_context('webapp scale') as c: + c.argument('instance_count', help='Number of instances', type=int, default=1) + + with self.argument_context('webapp show') as c: + c.argument('name', arg_type=webapp_name_arg_type) + + with self.argument_context('functionapp') as c: + c.ignore('app_instance') + c.argument('name', arg_type=name_arg_type, id_part='name', help='name of the function app') + c.argument('slot', options_list=['--slot', '-s'], + help="the name of the slot. Default to the productions slot if not specified") + + with self.argument_context('functionapp config container set') as c: + c.argument('docker_custom_image_name', options_list=['--docker-custom-image-name', '-c', '-i'], + help='the container custom image name and optionally the tag name') + c.argument('docker_registry_server_password', options_list=['--docker-registry-server-password', '-p'], + help='the container registry server password') + c.argument('docker_registry_server_url', options_list=['--docker-registry-server-url', '-r'], + help='the container registry server url') + c.argument('docker_registry_server_user', options_list=['--docker-registry-server-user', '-u'], + help='the container registry server username') + + with self.argument_context('functionapp create') as c: + c.argument('plan', options_list=['--plan', '-p'], configured_default='appserviceplan', + completer=get_resource_name_completion_list('Microsoft.Web/serverFarms'), + help="name or resource id of the function app service plan. Use 'appservice plan create' to get one") + c.argument('new_app_name', options_list=['--name', '-n'], help='name of the new function app') + c.argument('custom_location', help="Name or ID of the custom location") + c.argument('storage_account', options_list=['--storage-account', '-s'], + help='Provide a string value of a Storage Account in the provided Resource Group. Or Resource ID of a Storage Account in a different Resource Group') + c.argument('consumption_plan_location', options_list=['--consumption-plan-location', '-c'], + help="Geographic location where Function App will be hosted. Use `az functionapp list-consumption-locations` to view available locations.") + c.argument('functions_version', help='The functions app version.', arg_type=get_enum_type(FUNCTIONS_VERSIONS)) + c.argument('runtime', help='The functions runtime stack.', arg_type=get_enum_type(set(LINUX_RUNTIMES).union(set(WINDOWS_RUNTIMES)))) + c.argument('runtime_version', help='The version of the functions runtime stack. ' + 'Allowed values for each --runtime are: ' + ', '.join(functionapp_runtime_to_version_texts)) + c.argument('os_type', arg_type=get_enum_type(OS_TYPES), help="Set the OS type for the app to be created.") + c.argument('app_insights_key', help="Instrumentation key of App Insights to be added.") + c.argument('app_insights', help="Name of the existing App Insights project to be added to the Function app. Must be in the same resource group.") + c.argument('disable_app_insights', arg_type=get_three_state_flag(return_label=True), help="Disable creating application insights resource during functionapp create. No logs will be available.") + c.argument('docker_registry_server_user', help='The container registry server username.') + c.argument('docker_registry_server_password', help='The container registry server password. Required for private registries.') + + for scope in ['webapp', 'functionapp']: + with self.argument_context(scope + ' create') as c: + c.argument('assign_identities', nargs='*', options_list=['--assign-identity'], + help='accept system or user assigned identities separated by spaces. Use \'[system]\' to refer system assigned identity, or a resource id to refer user assigned identity. Check out help for more examples') + c.argument('scope', help="Scope that the system assigned identity can access") + c.argument('role', help="Role name or id the system assigned identity will have") + + c.argument('deployment_container_image_name', options_list=['--deployment-container-image-name', '-i'], help='Linux only. Container image name from Docker Hub, e.g. publisher/image-name:tag') + c.argument('deployment_local_git', action='store_true', options_list=['--deployment-local-git', '-l'], help='enable local git') + c.argument('deployment_zip', options_list=['--deployment-zip', '-z'], help='perform deployment using zip file') + c.argument('deployment_source_url', options_list=['--deployment-source-url', '-u'], help='Git repository URL to link with manual integration') + c.argument('deployment_source_branch', options_list=['--deployment-source-branch', '-b'], help='the branch to deploy') + c.argument('min_worker_count', help='Minimum number of workers to be allocated.', type=int, default=None, is_preview=True) + c.argument('max_worker_count', help='Maximum number of workers to be allocated.', type=int, default=None, is_preview=True) + c.argument('tags', arg_type=tags_type) + + with self.argument_context(scope + ' deployment source config-zip') as c: + c.argument('src', help='a zip file path for deployment') + c.argument('build_remote', help='enable remote build during deployment', + arg_type=get_three_state_flag(return_label=True)) + c.argument('timeout', type=int, options_list=['--timeout', '-t'], + help='Configurable timeout in seconds for checking the status of deployment', + validator=validate_timeout_value) + c.argument('is_kube', help='the app is a kubernetes app') + + with self.argument_context('appservice') as c: + c.argument('resource_group_name', arg_type=resource_group_name_type) + c.argument('location', arg_type=get_location_type(self.cli_ctx)) + + with self.argument_context('appservice plan') as c: + c.argument('name', arg_type=name_arg_type, help='The name of the app service plan', + completer=get_resource_name_completion_list('Microsoft.Web/serverFarms'), + configured_default='appserviceplan', id_part='name') + c.argument('number_of_workers', help='Number of workers to be allocated.', type=int, default=1) + c.argument('admin_site_name', help='The name of the admin web app.', deprecate_info=c.deprecate(expiration='0.2.17')) + c.ignore('max_burst') + + with self.argument_context('appservice plan create') as c: + c.argument('name', arg_type=name_arg_type, help="Name of the new app service plan", completer=None, + validator=validate_asp_create) + c.argument('app_service_environment', options_list=['--app-service-environment', '-e'], + help="Name or ID of the app service environment") + c.argument('custom_location', options_list=['--custom-location', '-c'], help="Name or ID of the custom location") + c.argument('sku', + 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), PC2 (Premium Container Small), PC3 (Premium Container Medium), PC4 (Premium Container Large), I1 (Isolated Small), I2 (Isolated Medium), I3 (Isolated Large), K1 (Kubernetes)') + c.argument('is_linux', action='store_true', required=False, help='host web app on Linux worker') + c.argument('hyper_v', action='store_true', required=False, help='Host web app on Windows container', is_preview=True) + c.argument('per_site_scaling', action='store_true', required=False, help='Enable per-app scaling at the ' + 'App Service plan level to allow for ' + 'scaling an app independently from ' + 'the App Service plan that hosts it.') + c.argument('tags', arg_type=tags_type) + + with self.argument_context('appservice plan update') as c: + c.argument('sku', + 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), PC2 (Premium Container Small), PC3 (Premium Container Medium), PC4 (Premium Container Large), I1 (Isolated Small), I2 (Isolated Medium), I3 (Isolated Large), K1 (Kubernetes)', + arg_type=sku_arg_type) + c.ignore('allow_pending_state') + + # App Service on Kubernetes Commands + with self.argument_context('appservice kube create') as c: + c.argument('name', arg_type=name_arg_type, help='Name of the kubernetes environment.') + c.argument('custom_location', options_list=['--custom-location', '-c'], help="ID of the custom location") + c.argument('tags', arg_type=tags_type) + c.argument('static_ip', help='Static IP Address. This is required if an AKS resource ID is specified.') + c.argument('no_wait', help='Do not wait for the create to complete, and return immediately after queuing the create.') + + with self.argument_context('appservice kube update') as c: + c.argument('name', arg_type=name_arg_type, help='Name of the kubernetes environment.') + c.argument('tags', arg_type=tags_type) + c.argument('custom_location', options_list=['--custom-location', '-c'], help="ID of the custom location") + c.argument('static_ip', help='New Static IP Address.') + + with self.argument_context('appservice kube delete') as c: + c.argument('name', arg_type=name_arg_type, help='Name of the Kubernetes Environment.') + c.argument('force_delete', options_list=['--force', '-f'], arg_type=get_three_state_flag(), help='Force deletion even if the Kubernetes ' + 'Environment contains resources.') + + with self.argument_context('appservice kube show') as c: + c.argument('name', arg_type=name_arg_type, help='Name of the Kubernetes Environment.') + + with self.argument_context('appservice kube wait') as c: + c.argument('name', arg_type=name_arg_type, help='Name of the Kubernetes Environment.') diff --git a/src/appservice-kube/azext_appservice_kube/_utils.py b/src/appservice-kube/azext_appservice_kube/_utils.py new file mode 100644 index 00000000000..28b98fa1a8d --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/_utils.py @@ -0,0 +1,129 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.azclierror import ValidationError, InvalidArgumentValueError, ArgumentUsageError +from ._client_factory import web_client_factory, cf_resource_groups + + +def _normalize_sku(sku): + sku = sku.upper() + if sku == 'FREE': + return 'F1' + if sku == 'SHARED': + return 'D1' + if sku in ('ANY', 'ELASTICANY'): # old kube skus + return 'K1' + if sku == 'KUBE': + return 'K1' + return sku + + +def _validate_asp_sku(app_service_environment, custom_location, sku): + # Isolated SKU is supported only for ASE + if sku.upper() not in ['F1', 'FREE', 'D1', 'SHARED', 'B1', 'B2', 'B3', 'S1', 'S2', 'S3', 'P1V2', 'P1V3', 'P2V2', + 'P3V2', 'PC2', 'PC3', 'PC4', 'I1', 'I2', 'I3', 'K1']: + raise InvalidArgumentValueError('Invalid sku entered: {}'.format(sku)) + + if sku.upper() in ['I1', 'I2', 'I3', 'I1V2', 'I2V2', 'I3V2']: + if not app_service_environment: + raise ValidationError("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") + elif app_service_environment: + raise ValidationError("Only pricing tier 'Isolated' is allowed in this app service plan. Use this link to " + "learn more: https://docs.microsoft.com/en-us/azure/app-service/overview-hosting-plans") + elif custom_location: + # Custom Location only supports K1 + if sku.upper() != 'K1': + raise ValidationError("Only pricing tier 'K1' is allowed for this type of app service plan.") + + +def get_sku_name(tier): # pylint: disable=too-many-return-statements + tier = tier.upper() + if tier in ['F1', 'FREE']: + return 'FREE' + if tier in ['D1', "SHARED"]: + return 'SHARED' + if tier in ['B1', 'B2', 'B3', 'BASIC']: + return 'BASIC' + if tier in ['S1', 'S2', 'S3']: + return 'STANDARD' + if tier in ['P1', 'P2', 'P3']: + return 'PREMIUM' + if tier in ['P1V2', 'P2V2', 'P3V2']: + return 'PREMIUMV2' + if tier in ['P1V3', 'P2V3', 'P3V3']: + return 'PREMIUMV3' + if tier in ['PC2', 'PC3', 'PC4']: + return 'PremiumContainer' + if tier in ['EP1', 'EP2', 'EP3']: + return 'ElasticPremium' + if tier in ['I1', 'I2', 'I3']: + return 'Isolated' + if tier in ['I1V2', 'I2V2', 'I3V2']: + return 'IsolatedV2' + if tier in ['K1']: + return 'Kubernetes' + raise InvalidArgumentValueError("Invalid sku(pricing tier), please refer to command help for valid values") + + +def validate_subnet_id(cli_ctx, subnet, vnet_name, resource_group_name): + from msrestazure.tools import is_valid_resource_id + subnet_is_id = is_valid_resource_id(subnet) + + if subnet_is_id and not vnet_name: + return subnet + if subnet and not subnet_is_id and vnet_name: + from msrestazure.tools import resource_id + from azure.cli.core.commands.client_factory import get_subscription_id + return resource_id( + subscription=get_subscription_id(cli_ctx), + resource_group=resource_group_name, + namespace='Microsoft.Network', + type='virtualNetworks', + name=vnet_name, + child_type_1='subnets', + child_name_1=subnet) + raise ArgumentUsageError('Usage error: --subnet ID | --subnet NAME --vnet-name NAME') + + +def validate_aks_id(cli_ctx, aks, resource_group_name): + from msrestazure.tools import is_valid_resource_id + aks_is_id = is_valid_resource_id(aks) + + if aks_is_id: + return aks + if aks and not aks_is_id: + from msrestazure.tools import resource_id + from azure.cli.core.commands.client_factory import get_subscription_id + return resource_id( + subscription=get_subscription_id(cli_ctx), + resource_group=resource_group_name, + namespace='Microsoft.ContainerService', + type='managedClusters', + name=aks) + raise ArgumentUsageError('Usage error: --aks') + + +def _generic_site_operation(cli_ctx, resource_group_name, name, operation_name, slot=None, + extra_parameter=None, client=None, api_version=None): + # api_version was added to support targeting a specific API + # Based on get_appconfig_service_client example + client = client or web_client_factory(cli_ctx, api_version=api_version) + operation = getattr(client.web_apps, + operation_name if slot is None else operation_name + '_slot') + if slot is None: + return (operation(resource_group_name, name) + if extra_parameter is None else operation(resource_group_name, + name, extra_parameter)) + return (operation(resource_group_name, name, slot) + if extra_parameter is None else operation(resource_group_name, + name, slot, extra_parameter)) + + +def _get_location_from_resource_group(cli_ctx, resource_group_name): + client = cf_resource_groups(cli_ctx) + group = client.get(resource_group_name) + return group.location diff --git a/src/appservice-kube/azext_appservice_kube/_validators.py b/src/appservice-kube/azext_appservice_kube/_validators.py new file mode 100644 index 00000000000..0393681561a --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/_validators.py @@ -0,0 +1,110 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.util import CLIError +from azure.cli.core.commands.client_factory import get_mgmt_service_client +from ._client_factory import web_client_factory +from ._utils import _normalize_sku, _validate_asp_sku +from ._constants import KUBE_DEFAULT_SKU + + +def validate_asp_sku(cmd, namespace): + import json + client = web_client_factory(cmd.cli_ctx) + serverfarm = namespace.name + resource_group_name = namespace.resource_group_name + asp = client.app_service_plans.get(resource_group_name, serverfarm, None, raw=True) + if asp.response.status_code != 200: + raise CLIError(asp.response.text) + # convert byte array to json + output_str = asp.response.content.decode('utf8') + res = json.loads(output_str) + + # Isolated SKU is supported only for ASE + if namespace.sku in ['I1', 'I2', 'I3']: + if res.get('properties').get('hostingEnvironment') is None: + 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") + else: + if res.get('properties').get('hostingEnvironment') is not None: + raise CLIError("Only pricing tier 'Isolated' is allowed in this app service plan. Use this link to " + "learn more: https://docs.microsoft.com/en-us/azure/app-service/overview-hosting-plans") + + +def validate_asp_create(cmd, namespace): + """Validate the SiteName that is being used to create is available + This API requires that the RG is already created""" + + # need to validate SKU before the general ASP create validation + sku = namespace.sku + if not sku: + sku = 'B1' if not namespace.custom_location else KUBE_DEFAULT_SKU + sku = _normalize_sku(sku) + _validate_asp_sku(namespace.app_service_environment, namespace.custom_location, sku) + + client = web_client_factory(cmd.cli_ctx) + if isinstance(namespace.name, str) and isinstance(namespace.resource_group_name, str): + resource_group_name = namespace.resource_group_name + if isinstance(namespace.location, str): + location = namespace.location + else: + from azure.cli.core.profiles import ResourceType + rg_client = get_mgmt_service_client(cmd.cli_ctx, ResourceType.MGMT_RESOURCE_RESOURCES) + + group = rg_client.resource_groups.get(resource_group_name) + location = group.location + validation_payload = { + "name": namespace.name, + "type": "Microsoft.Web/serverfarms", + "location": location, + "properties": { + "skuName": sku, + "capacity": namespace.number_of_workers or 1, + "needLinuxWorkers": namespace.is_linux if namespace.custom_location is None else 'false', + "isXenon": namespace.hyper_v + } + } + validation = client.validate(resource_group_name, validation_payload) + if validation.status.lower() == "failure" and validation.error.code != 'ServerFarmAlreadyExists': + raise CLIError(validation.error.message) + + +def validate_nodes_count(namespace): + """Validates that node_count and max_count is set between 1-100""" + if namespace.node_count is not None: + if namespace.node_count < 1 or namespace.node_count > 100: + raise CLIError('--node-count must be in the range [1,100]') + if namespace.max_count is not None: + if namespace.max_count < 1 or namespace.max_count > 100: + raise CLIError('--max-count must be in the range [1,100]') + + +def validate_nodepool_name(namespace): + """Validates a nodepool name to be at most 12 characters, alphanumeric only.""" + if namespace.nodepool_name != "": + if len(namespace.nodepool_name) > 12: + raise CLIError('--nodepool-name can contain at most 12 characters') + if not namespace.nodepool_name.isalnum(): + raise CLIError('--nodepool-name should contain only alphanumeric characters') + + +def validate_app_or_slot_exists_in_rg(cmd, namespace): + """Validate that the App/slot exists in the RG provided""" + client = web_client_factory(cmd.cli_ctx) + webapp = namespace.name + resource_group_name = namespace.resource_group_name + if isinstance(namespace.slot, str): + app = client.web_apps.get_slot(resource_group_name, webapp, namespace.slot, raw=True) + else: + app = client.web_apps.get(resource_group_name, webapp, None, raw=True) + if app.response.status_code != 200: + raise CLIError(app.response.text) + + +def validate_timeout_value(namespace): + """Validates that zip deployment timeout is set to a reasonable min value""" + if isinstance(namespace.timeout, int): + if namespace.timeout <= 29: + raise CLIError('--timeout value should be a positive value in seconds and should be at least 30') diff --git a/src/appservice-kube/azext_appservice_kube/azext_metadata.json b/src/appservice-kube/azext_appservice_kube/azext_metadata.json new file mode 100644 index 00000000000..0ef8a520954 --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/azext_metadata.json @@ -0,0 +1,4 @@ +{ + "azext.isPreview": true, + "azext.minCliCoreVersion": "2.26.0" +} diff --git a/src/appservice-kube/azext_appservice_kube/commands.py b/src/appservice-kube/azext_appservice_kube/commands.py new file mode 100644 index 00000000000..cc5b065a754 --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/commands.py @@ -0,0 +1,86 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=line-too-long +from azure.cli.core.commands import CliCommandType + +from ._client_factory import cf_plans + + +def transform_web_output(web): + props = ['name', 'state', 'location', 'resourceGroup', 'defaultHostName', 'appServicePlanId', 'ftpPublishingUrl'] + result = {k: web[k] for k in web if k in props} + # to get width under control, also the plan usually is in the same RG + result['appServicePlan'] = result.pop('appServicePlanId').split('/')[-1] + return result + + +def ex_handler_factory(creating_plan=False): + def _polish_bad_errors(ex): + import json + from knack.util import CLIError + try: + if 'text/plain' in ex.response.headers['Content-Type']: # HTML Response + detail = ex.response.text + else: + detail = json.loads(ex.response.text)['Message'] + if creating_plan: + if 'Requested features are not supported in region' in detail: + detail = ("Plan with linux worker is not supported in current region. For " + + "supported regions, please refer to https://docs.microsoft.com/" + "azure/app-service-web/app-service-linux-intro") + elif 'Not enough available reserved instance servers to satisfy' in detail: + detail = ("Plan with Linux worker can only be created in a group " + + "which has never contained a Windows worker, and vice versa. " + + "Please use a new resource group. Original error:" + detail) + ex = CLIError(detail) + except Exception: # pylint: disable=broad-except + pass + raise ex + return _polish_bad_errors + + +def load_command_table(self, _): + appservice_plan_sdk = CliCommandType( + operations_tmpl='azure.mgmt.web.operations#AppServicePlansOperations.{}', + client_factory=cf_plans + ) + + with self.command_group('appservice kube', is_preview=True) as g: + g.custom_show_command('show', 'show_kube_environments') + g.custom_wait_command('wait', 'show_kube_environments') + g.custom_command('list', 'list_kube_environments') + g.custom_command('create', 'create_kube_environment', supports_no_wait=True) + g.custom_command('update', 'update_kube_environment', supports_no_wait=True) + g.custom_command('delete', 'delete_kube_environment', supports_no_wait=True, confirmation=True) + + with self.command_group('appservice plan', appservice_plan_sdk) as g: + g.custom_command('create', 'create_app_service_plan', supports_no_wait=True, + exception_handler=ex_handler_factory(creating_plan=True)) + + g.custom_command('update', 'update_app_service_plan', supports_no_wait=True) + + g.show_command('show', 'get') + g.custom_command('list', 'list_app_service_plans') + + with self.command_group('webapp') as g: + g.custom_command('create', 'create_webapp', exception_handler=ex_handler_factory()) + g.custom_show_command('show', 'show_webapp', table_transformer=transform_web_output) + g.custom_command('scale', 'scale_webapp') + g.custom_command('restart', 'restart_webapp') + + with self.command_group('webapp deployment source') as g: + g.custom_command('config-zip', 'enable_zip_deploy_webapp') + + with self.command_group('functionapp') as g: + g.custom_command('create', 'create_function', exception_handler=ex_handler_factory()) + g.custom_show_command('show', 'show_webapp', table_transformer=transform_web_output) + g.custom_command('restart', 'restart_webapp') + + with self.command_group('functionapp config container') as g: + g.custom_command('set', 'update_container_settings_functionapp') + + with self.command_group('functionapp deployment source') as g: + g.custom_command('config-zip', 'enable_zip_deploy_functionapp') diff --git a/src/appservice-kube/azext_appservice_kube/custom.py b/src/appservice-kube/azext_appservice_kube/custom.py new file mode 100644 index 00000000000..41d9d165441 --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/custom.py @@ -0,0 +1,1677 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import time + +from binascii import hexlify +from os import urandom +import json + +from knack.util import CLIError +from knack.log import get_logger + +from azure.cli.core.util import send_raw_request, sdk_no_wait, get_json_object +from azure.cli.core.commands.client_factory import get_subscription_id +from azure.cli.command_modules.appservice.custom import ( + update_container_settings, + _rename_server_farm_props, + get_site_configs, + get_webapp, + _get_site_credential, + _format_fx_version, + _get_extension_version_functionapp, + _validate_app_service_environment_id, + _get_location_from_webapp, + validate_and_convert_to_int, + validate_range_of_int_flag, + get_app_settings, + get_app_insights_key, + _update_webapp_current_stack_property_if_needed, + validate_container_app_create_options, + parse_docker_image_name, + list_consumption_locations, + is_plan_elastic_premium, + enable_local_git, + _validate_and_get_connection_string, + _get_linux_multicontainer_encoded_config_from_file, + _StackRuntimeHelper, + upload_zip_to_storage, + is_plan_consumption, + _configure_default_logging, + assign_identity, + delete_app_settings, + update_app_settings) +from azure.cli.command_modules.appservice.utils import retryable_method +from azure.cli.core.commands.client_factory import get_mgmt_service_client +from azure.cli.core.commands import LongRunningOperation +from azure.mgmt.applicationinsights import ApplicationInsightsManagementClient +from azure.cli.core.util import get_az_user_agent +from azure.cli.core.azclierror import (ResourceNotFoundError, RequiredArgumentMissingError, ValidationError, + ArgumentUsageError, MutuallyExclusiveArgumentError) + +from msrestazure.tools import is_valid_resource_id, parse_resource_id + +from ._constants import (FUNCTIONS_VERSION_TO_DEFAULT_RUNTIME_VERSION, FUNCTIONS_VERSION_TO_DEFAULT_NODE_VERSION, + FUNCTIONS_VERSION_TO_SUPPORTED_RUNTIME_VERSIONS, NODE_EXACT_VERSION_DEFAULT, + DOTNET_RUNTIME_VERSION_TO_DOTNET_LINUX_FX_VERSION, KUBE_DEFAULT_SKU, + KUBE_ASP_KIND, KUBE_APP_KIND, KUBE_FUNCTION_APP_KIND, KUBE_FUNCTION_CONTAINER_APP_KIND, + KUBE_CONTAINER_APP_KIND, LINUX_RUNTIMES, WINDOWS_RUNTIMES) + +from ._utils import (_normalize_sku, get_sku_name, _generic_site_operation, + _get_location_from_resource_group, _validate_asp_sku) +from ._create_util import (get_app_details, get_site_availability, get_current_stack_from_runtime, + generate_default_app_service_plan_name) +from ._client_factory import web_client_factory, ex_handler_factory, customlocation_client_factory + + +logger = get_logger(__name__) + +# pylint: disable=too-many-locals,too-many-lines + + +# TODO remove and replace with calls to KubeEnvironmentsOperations once the SDK gets updated +class KubeEnvironmentClient(): + @classmethod + def create(cls, cmd, resource_group_name, name, kube_environment_envelope): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = "2020-12-01" + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/kubeEnvironments/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "PUT", request_url, body=json.dumps(kube_environment_envelope)) + return r.json() + + @classmethod + def update(cls, cmd, name, resource_group_name, kube_environment_envelope): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = "2020-12-01" + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/kubeEnvironments/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "PATCH", request_url, body=json.dumps(kube_environment_envelope)) + return r.json() + + @classmethod + def show(cls, cmd, resource_group_name, name): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = "2020-12-01" + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/kubeEnvironments/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "GET", request_url) + return r.json() + + @classmethod + def delete(cls, cmd, resource_group_name, name): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = "2020-12-01" + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/kubeEnvironments/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + send_raw_request(cmd.cli_ctx, "DELETE", request_url) # API doesn't return JSON for some reason + + @classmethod + def list_by_subscription(cls, cmd, formatter=lambda x: x): + kube_list = [] + + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = "2020-12-01" + sub_id = get_subscription_id(cmd.cli_ctx) + request_url = "{}/subscriptions/{}/providers/Microsoft.Web/kubeEnvironments?api-version={}".format( + management_hostname.strip('/'), + sub_id, + api_version) + + r = send_raw_request(cmd.cli_ctx, "GET", request_url) + j = r.json() + for kube in j["value"]: + formatted = formatter(kube) + kube_list.append(formatted) + + while j.get("nextLink") is not None: + request_url = j["nextLink"] + r = send_raw_request(cmd.cli_ctx, "GET", request_url) + j = r.json() + for kube in j["value"]: + formatted = formatter(kube) + kube_list.append(formatted) + + return kube_list + + @classmethod + def list_by_resource_group(cls, cmd, resource_group_name, formatter=lambda x: x): + kube_list = [] + + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = "2020-12-01" + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/kubeEnvironments?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "GET", request_url) + j = r.json() + for kube in j["value"]: + formatted = formatter(kube) + kube_list.append(formatted) + + while j.get("nextLink") is not None: + request_url = j["nextLink"] + r = send_raw_request(cmd.cli_ctx, "GET", request_url) + j = r.json() + for kube in j["value"]: + formatted = formatter(kube) + kube_list.append(formatted) + + return kube_list + + +class AppServiceClient(): + @classmethod + def create(cls, cmd, name, resource_group_name, appservice_json): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = "2020-12-01" + sub_id = get_subscription_id(cmd.cli_ctx) + + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/serverfarms/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "PUT", request_url, body=json.dumps(appservice_json)) + return r.json() + + @classmethod + def update(cls, cmd, name, resource_group_name, appservice_json): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = "2020-12-01" + sub_id = get_subscription_id(cmd.cli_ctx) + + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/serverfarms/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "PUT", request_url, body=json.dumps(appservice_json)) + return r.json() + + @classmethod + def show(cls, cmd, name, resource_group_name): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = "2020-12-01" + sub_id = get_subscription_id(cmd.cli_ctx) + + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/serverfarms/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "GET", request_url) + return r.json() + + +class WebAppClient: + @classmethod + def create(cls, cmd, name, resource_group_name, webapp_json): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = "2020-12-01" + sub_id = get_subscription_id(cmd.cli_ctx) + + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/sites/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "PUT", request_url, body=json.dumps(webapp_json)) + return r.json() + + @classmethod + def restart(cls, cmd, resource_group_name, name, slot=None): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = "2020-12-01" + sub_id = get_subscription_id(cmd.cli_ctx) + + if slot is not None: + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/"\ + "Microsoft.Web/sites/{}/slots/{}/restart?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + slot, + api_version) + else: + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/sites/{}/restart?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + send_raw_request(cmd.cli_ctx, "POST", request_url) + + +# rectify the format of the kube env json returned from API to comply with older version of `az appservice kube show` +def format_kube_environment_json(kube_info_raw): + kube_info = kube_info_raw["properties"] + if kube_info.get("aksResourceID"): + kube_info["aksResourceId"] = kube_info["aksResourceID"] + del kube_info["aksResourceID"] + + other_properties = ['id', 'kind', 'kubeEnvironmentType', 'location', 'name', + 'resourceGroup', 'tags', 'type', 'extendedLocation'] + for k in other_properties: + kube_info[k] = kube_info_raw.get(k) + + return kube_info + + +def show_kube_environments(cmd, name, resource_group_name): + return format_kube_environment_json(KubeEnvironmentClient.show(cmd=cmd, + name=name, resource_group_name=resource_group_name)) + + +def delete_kube_environment(cmd, name, resource_group_name): + # Raises an exception if the kube environment doesn't exist + KubeEnvironmentClient.show(cmd=cmd, resource_group_name=resource_group_name, name=name) + + return KubeEnvironmentClient.delete(cmd=cmd, name=name, resource_group_name=resource_group_name) + + +def create_kube_environment(cmd, name, resource_group_name, custom_location, static_ip=None, location=None, + tags=None, no_wait=False): + # pylint: disable=broad-except,no-member,protected-access + + custom_location_client = customlocation_client_factory(cmd.cli_ctx) + custom_location_object = None + + if is_valid_resource_id(custom_location): + parsed_custom_location = parse_resource_id(custom_location) + if parsed_custom_location['resource_type'].lower() != 'customlocations': + raise CLIError('Invalid custom location') + custom_location_object = custom_location_client.custom_locations.get( + parsed_custom_location['resource_group'], + parsed_custom_location['name']) + else: + custom_location_object = custom_location_client.custom_locations.get(resource_group_name, custom_location) + custom_location = custom_location_object.id + + if not location: + location = custom_location_object.location + + front_end_configuration = {"kind": "LoadBalancer"} + + extended_location = {"customLocation": custom_location} + + arc_configuration = { + "artifactsStorageType": "NetworkFileSystem", + "artifactStorageClassName": "default", + "frontEndServiceConfiguration": front_end_configuration + } + + kube_environment = { + "kind": None, + "location": location, + "tags": tags, + "properties": { + "extendedLocation": extended_location, + "staticIp": static_ip, + "arcConfiguration": arc_configuration + } + } + + try: + return sdk_no_wait(no_wait, KubeEnvironmentClient.create, + cmd=cmd, resource_group_name=resource_group_name, + name=name, kube_environment_envelope=kube_environment) + except Exception as e: + try: + msg = json.loads(e.response._content)['Message'] + except Exception as e2: + raise e from e2 + raise ValidationError(msg) + + +def list_kube_environments(cmd, resource_group_name=None): + if resource_group_name is None: + return KubeEnvironmentClient.list_by_subscription(cmd, formatter=format_kube_environment_json) + return KubeEnvironmentClient.list_by_resource_group(cmd, + resource_group_name, + formatter=format_kube_environment_json) + + +# TODO should be able to update staticIp and tags -- remove exception once API fixed +def update_kube_environment(cmd, name, resource_group_name, custom_location=None, static_ip=None, + tags=None, no_wait=False): + raise CLIError("Update is not yet supported for Kubernetes Environments.") + + # Raises an exception if the kube environment doesn't exist + # KubeEnvironmentClient.show(cmd=cmd, resource_group_name=resource_group_name, name=name) + + # front_end_configuration = {"kind": "LoadBalancer"} + + # extended_location = {"customLocation": custom_location} + + # arc_configuration = { + # "artifactsStorageType": "NetworkFileSystem", + # "artifactStorageClassName": "default", + # "frontEndServiceConfiguration": front_end_configuration + # } + + # kube_environment = { + # "kind": None, + # "location": location, + # "properties": { + # "extendedLocation": extended_location, + # "staticIp": static_ip, + # "arcConfiguration": arc_configuration + # } + # } + + # if tags is not None: + # kube_environment["tags"] = tags + + # return sdk_no_wait(no_wait, KubeEnvironmentClient.update, cmd=cmd, resource_group_name=resource_group_name, + # name=name, kube_environment_envelope=kube_environment) + + +def list_app_service_plans(cmd, resource_group_name=None): + client = web_client_factory(cmd.cli_ctx) + if resource_group_name is None: + plans = list(client.app_service_plans.list()) + else: + plans = list(client.app_service_plans.list_by_resource_group(resource_group_name)) + for plan in plans: + # prune a few useless fields + del plan.geo_region + del plan.subscription + return plans + + +def create_app_service_plan(cmd, resource_group_name, name, is_linux, hyper_v, per_site_scaling=False, + custom_location=None, + app_service_environment=None, sku=None, + number_of_workers=None, location=None, tags=None, no_wait=False): + if not sku: + sku = 'B1' if not custom_location else KUBE_DEFAULT_SKU + + if custom_location: + if not per_site_scaling: + raise ArgumentUsageError('Per Site Scaling must be true when using Custom Location. ' + 'Please re-run with --per-site-scaling flag') + if app_service_environment: + raise ArgumentUsageError('App Service Environment is not supported with using Custom Location') + if hyper_v: + raise ArgumentUsageError('Hyper V is not supported with using Custom Location') + if not is_linux: + raise ArgumentUsageError('Only Linux is supported with using Custom Location. ' + 'Please re-run with --is-linux flag.') + + return create_app_service_plan_inner(cmd, resource_group_name, name, is_linux, hyper_v, per_site_scaling, + custom_location, app_service_environment, sku, number_of_workers, location, + tags, no_wait) + + +def get_vm_sizes(cli_ctx, location): + from ._client_factory import cf_compute_service + + return cf_compute_service(cli_ctx).virtual_machine_sizes.list(location) + + +def _get_kube_env_from_custom_location(cmd, custom_location, resource_group): + kube_environment_id = "" + custom_location_name = custom_location + + if is_valid_resource_id(custom_location): + parsed_custom_location = parse_resource_id(custom_location) + custom_location_name = parsed_custom_location.get("name") + resource_group = parsed_custom_location.get("resource_group") + + kube_envs = KubeEnvironmentClient.list_by_subscription(cmd=cmd) + + for kube in kube_envs: + parsed_custom_location_2 = None + + if kube.get("properties") and kube["properties"].get('extendedLocation'): + parsed_custom_location_2 = parse_resource_id(kube["properties"]['extendedLocation']['customLocation']) + elif kube.get("extendedLocation") and kube.get("extendedLocation").get("type") == "CustomLocation": + parsed_custom_location_2 = parse_resource_id(kube["extendedLocation"]["name"]) + + if parsed_custom_location_2 and ( + parsed_custom_location_2.get("name").lower() == custom_location_name.lower()) and ( + parsed_custom_location_2.get("resource_group").lower() == resource_group.lower()): + kube_environment_id = kube.get("id") + break + + if not kube_environment_id: + raise ResourceNotFoundError('Unable to find Kube Environment associated to the Custom Location') + + return kube_environment_id + + +def _get_custom_location_id_from_custom_location(cmd, custom_location_name, resource_group_name): + if is_valid_resource_id(custom_location_name): + return custom_location_name + + kube_envs = KubeEnvironmentClient.list_by_resource_group(cmd=cmd, resource_group_name=resource_group_name) + + for kube in kube_envs: + parsed_custom_location = None + custom_location_id = None + + if kube.additional_properties and 'extendedLocation' in kube.additional_properties: + custom_location_id = kube.additional_properties['extendedLocation'].get('name') + parsed_custom_location = parse_resource_id(custom_location_id) + elif kube.extended_location and kube.extended_location.custom_location: + custom_location_id = kube.extended_location.custom_location + parsed_custom_location = parse_resource_id(custom_location_id) + + if parsed_custom_location and parsed_custom_location.get("name").lower() == custom_location_name.lower(): + return custom_location_id + return None + + +def _get_custom_location_id_from_kube_env(kube): + if kube.get("properties") and kube["properties"].get("extendedLocation"): + return kube["properties"]['extendedLocation'].get('customLocation') + if kube.get("extendedLocation") and kube["extendedLocation"].get("type") == "CustomLocation": + return kube["extendedLocation"]["name"] + raise CLIError("Could not get custom location from kube environment") + + +def _ensure_kube_settings_in_json(appservice_plan_json, extended_location=None, kube_env=None): + if appservice_plan_json.get("properties") and (appservice_plan_json["properties"].get("kubeEnvironmentProfile") + is None and kube_env is not None): + appservice_plan_json["properties"]["kubeEnvironmentProfile"] = kube_env + + if appservice_plan_json.get("extendedLocation") is None and extended_location is not None: + appservice_plan_json["extendedLocation"] = extended_location + + +def create_app_service_plan_inner(cmd, resource_group_name, name, is_linux, hyper_v, per_site_scaling=False, + custom_location=None, app_service_environment=None, sku=None, + number_of_workers=None, location=None, tags=None, no_wait=False): + HostingEnvironmentProfile, SkuDescription, AppServicePlan = cmd.get_models( + 'HostingEnvironmentProfile', 'SkuDescription', 'AppServicePlan') + + sku = _normalize_sku(sku) + _validate_asp_sku(app_service_environment, custom_location, sku) + + if is_linux and hyper_v: + raise MutuallyExclusiveArgumentError('Usage error: --is-linux and --hyper-v cannot be used together.') + + kube_environment = None + kind = None + + client = web_client_factory(cmd.cli_ctx) + + if custom_location: + kube_environment = _get_kube_env_from_custom_location(cmd, custom_location, resource_group_name) + + if app_service_environment: + if hyper_v: + raise ArgumentUsageError('Windows containers is not yet supported in app service environment') + ase_id = _validate_app_service_environment_id(cmd.cli_ctx, app_service_environment, resource_group_name) + ase_def = HostingEnvironmentProfile(id=ase_id) + ase_list = client.app_service_environments.list() + ase_found = False + for ase in ase_list: + if ase.name.lower() == app_service_environment.lower() or ase.id.lower() == ase_id.lower(): + location = ase.location + ase_found = True + break + if not ase_found: + raise CLIError("App service environment '{}' not found in subscription.".format(ase_id)) + else: # Non-ASE + ase_def = None + + extended_location_envelope = None + if kube_environment and (ase_def is None): + kube_id = _resolve_kube_environment_id(cmd.cli_ctx, kube_environment, resource_group_name) + # kube_def = KubeEnvironmentProfile(id=kube_id) + kube_def = {"id": kube_id} + kind = KUBE_ASP_KIND + parsed_id = parse_resource_id(kube_id) + kube_name = parsed_id.get("name") + kube_rg = parsed_id.get("resource_group") + if kube_name is not None and kube_rg is not None: + kube_env = KubeEnvironmentClient.show(cmd=cmd, resource_group_name=kube_rg, name=kube_name) + extended_location_envelope = {"name": _get_custom_location_id_from_kube_env(kube_env), + "type": "CustomLocation"} + + if kube_env is not None: + location = kube_env["location"] + else: + raise CLIError("Kube Environment '{}' not found in subscription.".format(kube_id)) + else: + kube_def = None + + if location is None: + location = _get_location_from_resource_group(cmd.cli_ctx, resource_group_name) + + # the api is odd on parameter naming, have to live with it for now + sku_def = SkuDescription(tier=get_sku_name(sku), name=sku, capacity=number_of_workers) + + plan_def = AppServicePlan(location=location, tags=tags, sku=sku_def, kind=kind, + reserved=(is_linux or None), hyper_v=(hyper_v or None), name=name, + per_site_scaling=per_site_scaling, hosting_environment_profile=ase_def, + kube_environment_profile=kube_def, extended_location=extended_location_envelope) + plan_json = plan_def.serialize() + _ensure_kube_settings_in_json(appservice_plan_json=plan_json, + extended_location=extended_location_envelope, kube_env=kube_def) + + return sdk_no_wait(no_wait, AppServiceClient.create, cmd=cmd, name=name, + resource_group_name=resource_group_name, appservice_json=plan_json) + + +def update_app_service_plan(cmd, resource_group_name, name, sku=None, number_of_workers=None, no_wait=False): + client = web_client_factory(cmd.cli_ctx) + plan = client.app_service_plans.get(resource_group_name, name).serialize() + plan_with_kube_env = AppServiceClient.show(cmd=cmd, name=name, resource_group_name=resource_group_name) + + if number_of_workers is None and sku is None: + logger.warning('No update is done. Specify --sku and/or --number-of-workers.') + sku_def = plan["sku"] + if sku is not None: + sku = _normalize_sku(sku) + sku_def["tier"] = get_sku_name(sku) + sku_def["name"] = sku + + if number_of_workers is not None: + sku_def["capacity"] = number_of_workers + + plan["sku"] = sku_def + + _ensure_kube_settings_in_json(appservice_plan_json=plan, + extended_location=plan_with_kube_env.get("extendedLocation"), + kube_env=plan_with_kube_env["properties"].get("kubeEnvironmentProfile")) + + return sdk_no_wait(no_wait, AppServiceClient.update, cmd=cmd, name=name, + resource_group_name=resource_group_name, appservice_json=plan) + + +def _validate_asp_and_custom_location_kube_envs_match(cmd, resource_group_name, custom_location, plan): + if is_valid_resource_id(plan): + parse_result = parse_resource_id(plan) + plan_info = AppServiceClient.show(cmd=cmd, name=parse_result['name'], + resource_group_name=parse_result["resource_group"]) + else: + plan_info = AppServiceClient.show(cmd=cmd, name=plan, resource_group_name=resource_group_name) + if not plan_info: + raise CLIError("The plan '{}' doesn't exist in the resource group '{}".format(plan, resource_group_name)) + + plan_kube_env_id = "" + custom_location_kube_env_id = _get_kube_env_from_custom_location(cmd, custom_location, resource_group_name) + if plan_info["properties"].get("kubeEnvironmentProfile"): + plan_kube_env_id = plan_info["properties"]["kubeEnvironmentProfile"]["id"] + + return plan_kube_env_id.lower() == custom_location_kube_env_id.lower() + + +def _should_create_new_appservice_plan_for_k8se(cmd, name, custom_location, plan, resource_group_name): + if custom_location and plan: + return False + if custom_location: + existing_app_details = get_app_details(cmd, name, resource_group_name) + if not existing_app_details: + return True + if _validate_asp_and_custom_location_kube_envs_match(cmd, resource_group_name, custom_location, + existing_app_details.server_farm_id): + return False # existing app and kube environments match + return True # existing app but new custom location + return False # plan is not None + + +def _is_webapp_kube(custom_location, plan_info, SkuDescription): + return custom_location or plan_info.kind.upper() == KUBE_ASP_KIND.upper() or ( + isinstance(plan_info.sku, SkuDescription) and plan_info.sku.name.upper() == KUBE_DEFAULT_SKU) + + +def create_webapp(cmd, resource_group_name, name, plan=None, runtime=None, custom_location=None, startup_file=None, # pylint: disable=too-many-statements,too-many-branches + deployment_container_image_name=None, deployment_source_url=None, deployment_source_branch='master', + deployment_local_git=None, docker_registry_server_password=None, docker_registry_server_user=None, + multicontainer_config_type=None, multicontainer_config_file=None, tags=None, + using_webapp_up=False, language=None, assign_identities=None, role='Contributor', scope=None, + min_worker_count=None, max_worker_count=None): + SiteConfig, SkuDescription, Site, NameValuePair, AppServicePlan = cmd.get_models( + 'SiteConfig', 'SkuDescription', 'Site', 'NameValuePair', "AppServicePlan") + if deployment_source_url and deployment_local_git: + raise CLIError('usage error: --deployment-source-url | --deployment-local-git') + + if not plan and not custom_location: + raise RequiredArgumentMissingError("Either Plan or Custom Location must be specified") + + # This is to keep the existing appsettings for a newly created webapp on existing webapp name. + name_validation = get_site_availability(cmd, name) + if not name_validation.name_available: + if name_validation.reason == 'Invalid': + raise CLIError(name_validation.message) + logger.warning("Webapp '%s' already exists. The command will use the existing app's settings.", name) + app_details = get_app_details(cmd, name, resource_group_name) + if app_details is None: + raise CLIError("Unable to retrieve details of the existing app '{}'. Please check that " + "the app is a part of the current subscription".format(name)) + current_rg = app_details.resource_group + if resource_group_name is not None and (resource_group_name.lower() != current_rg.lower()): + raise CLIError("The webapp '{}' exists in resource group '{}' and does not " + "match the value entered '{}'. Please re-run command with the " + "correct parameters.". format(name, current_rg, resource_group_name)) + existing_app_settings = _generic_site_operation(cmd.cli_ctx, resource_group_name, + name, 'list_application_settings') + settings = [] + for k, v in existing_app_settings.properties.items(): + settings.append(NameValuePair(name=k, value=v)) + site_config = SiteConfig(app_settings=settings) + else: + site_config = SiteConfig(app_settings=[]) + + _should_create_new_plan = _should_create_new_appservice_plan_for_k8se(cmd, + name, custom_location, + plan, resource_group_name) + if _should_create_new_plan: + plan = generate_default_app_service_plan_name(name) + logger.warning("Plan not specified. Creating Plan '%s' with sku '%s'", plan, KUBE_DEFAULT_SKU) + create_app_service_plan(cmd=cmd, resource_group_name=resource_group_name, + name=plan, is_linux=True, hyper_v=False, custom_location=custom_location, + per_site_scaling=True, number_of_workers=1) + + if custom_location and plan: + if not _validate_asp_and_custom_location_kube_envs_match(cmd, resource_group_name, custom_location, plan): + raise ValidationError("Custom location's kube environment and App Service Plan's " + "kube environment don't match") + elif custom_location and not plan: + app_details = get_app_details(cmd, name, resource_group_name) + if app_details is not None: + plan = app_details.server_farm_id + + docker_registry_server_url = parse_docker_image_name(deployment_container_image_name) + + client = web_client_factory(cmd.cli_ctx) + if is_valid_resource_id(plan): + parse_result = parse_resource_id(plan) + plan_info = AppServicePlan.from_dict(AppServiceClient.show(cmd=cmd, name=parse_result['name'], + resource_group_name=parse_result["resource_group"])) + else: + plan_info = AppServicePlan.from_dict(AppServiceClient.show(cmd=cmd, + name=plan, resource_group_name=resource_group_name)) + if not plan_info: + raise CLIError("The plan '{}' doesn't exist in the resource group '{}".format(plan, resource_group_name)) + + if custom_location: + _validate_asp_sku(app_service_environment=None, custom_location=custom_location, sku=plan_info.sku.name) + + is_linux = plan_info.reserved + node_default_version = NODE_EXACT_VERSION_DEFAULT + location = plan_info.location + + if isinstance(plan_info.sku, SkuDescription) and plan_info.sku.name.upper() not in ['F1', 'FREE', 'SHARED', 'D1', + 'B1', 'B2', 'B3', 'BASIC']: + site_config.always_on = True + webapp_def = Site(location=location, site_config=site_config, server_farm_id=plan_info.id, tags=tags, + https_only=using_webapp_up) + + is_kube = _is_webapp_kube(custom_location, plan_info, SkuDescription) + if is_kube: + if deployment_container_image_name: + webapp_def.kind = KUBE_CONTAINER_APP_KIND + else: + webapp_def.kind = KUBE_APP_KIND + + # if Custom Location provided, use that for Extended Location Envelope. Otherwise, get Custom Location from ASP + if custom_location: + webapp_def.enable_additional_properties_sending() + custom_location_id = _get_custom_location_id_from_custom_location(cmd, custom_location, resource_group_name) + if custom_location_id: + extended_loc = {'name': custom_location_id, 'type': 'CustomLocation'} + webapp_def.additional_properties["extendedLocation"] = extended_loc + else: + extended_loc = plan_info.additional_properties["extendedLocation"] + webapp_def.additional_properties["extendedLocation"] = extended_loc + + if is_kube: + if min_worker_count is not None: + site_config.number_of_workers = min_worker_count + + if max_worker_count is not None: + site_config.app_settings.append(NameValuePair(name='K8SE_APP_MAX_INSTANCE_COUNT', value=max_worker_count)) + + if deployment_container_image_name: + site_config.app_settings.append(NameValuePair(name='DOCKER_REGISTRY_SERVER_URL', + value=docker_registry_server_url)) + + if docker_registry_server_user is not None and docker_registry_server_password is not None: + site_config.app_settings.append(NameValuePair(name='DOCKER_REGISTRY_SERVER_USERNAME', + value=docker_registry_server_user)) + + site_config.app_settings.append(NameValuePair(name='DOCKER_REGISTRY_SERVER_PASSWORD', + value=docker_registry_server_password)) + helper = _StackRuntimeHelper(cmd, client, linux=(is_linux or is_kube)) + if runtime: + runtime = helper.remove_delimiters(runtime) + + current_stack = None + if is_linux or is_kube: + if not validate_container_app_create_options(runtime, deployment_container_image_name, + multicontainer_config_type, multicontainer_config_file): + raise CLIError("usage error: --runtime | --deployment-container-image-name |" + " --multicontainer-config-type TYPE --multicontainer-config-file FILE") + if startup_file: + site_config.app_command_line = startup_file + + if runtime: + site_config.linux_fx_version = runtime + match = helper.resolve(runtime) + if not match: + raise CLIError("Linux Runtime '{}' is not supported." + "Please invoke 'list-runtimes' to cross check".format(runtime)) + match['setter'](cmd=cmd, stack=match, site_config=site_config) + elif deployment_container_image_name: + site_config.linux_fx_version = _format_fx_version(deployment_container_image_name) + if name_validation.name_available: + site_config.app_settings.append(NameValuePair(name="WEBSITES_ENABLE_APP_SERVICE_STORAGE", + value="false")) + elif multicontainer_config_type and multicontainer_config_file: + encoded_config_file = _get_linux_multicontainer_encoded_config_from_file(multicontainer_config_file) + site_config.linux_fx_version = _format_fx_version(encoded_config_file, multicontainer_config_type) + + elif plan_info.is_xenon: # windows container webapp + if deployment_container_image_name: + site_config.windows_fx_version = _format_fx_version(deployment_container_image_name) + # set the needed app settings for container image validation + if name_validation.name_available: + site_config.app_settings.append(NameValuePair(name="DOCKER_REGISTRY_SERVER_USERNAME", + value=docker_registry_server_user)) + site_config.app_settings.append(NameValuePair(name="DOCKER_REGISTRY_SERVER_PASSWORD", + value=docker_registry_server_password)) + site_config.app_settings.append(NameValuePair(name="DOCKER_REGISTRY_SERVER_URL", + value=docker_registry_server_url)) + + elif runtime: # windows webapp with runtime specified + if any([startup_file, deployment_container_image_name, multicontainer_config_file, multicontainer_config_type]): + raise CLIError("usage error: --startup-file or --deployment-container-image-name or " + "--multicontainer-config-type and --multicontainer-config-file is " + "only appliable on linux webapp") + match = helper.resolve(runtime) + if not match: + raise CLIError("Windows runtime '{}' is not supported. " + "Please invoke 'az webapp list-runtimes' to cross check".format(runtime)) + match['setter'](cmd=cmd, stack=match, site_config=site_config) + + # portal uses the current_stack propety in metadata to display stack for windows apps + current_stack = get_current_stack_from_runtime(runtime) + + else: # windows webapp without runtime specified + if name_validation.name_available: # If creating new webapp + site_config.app_settings.append(NameValuePair(name="WEBSITE_NODE_DEFAULT_VERSION", + value=node_default_version)) + + if site_config.app_settings: + for setting in site_config.app_settings: + logger.info('Will set appsetting %s', setting) + if using_webapp_up: # when the routine is invoked as a help method for webapp up + if name_validation.name_available: + logger.info("will set appsetting for enabling build") + site_config.app_settings.append(NameValuePair(name="SCM_DO_BUILD_DURING_DEPLOYMENT", value=True)) + if language is not None and language.lower() == 'dotnetcore': + if name_validation.name_available: + site_config.app_settings.append(NameValuePair(name='ANCM_ADDITIONAL_ERROR_PAGE_LINK', + 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) + + if deployment_container_image_name: + update_container_settings(cmd, resource_group_name, name, docker_registry_server_url, + deployment_container_image_name, docker_registry_server_user, + docker_registry_server_password=docker_registry_server_password) + + if is_kube: + return webapp + + if current_stack: + _update_webapp_current_stack_property_if_needed(cmd, resource_group_name, name, current_stack) + + # Ensure SCC operations follow right after the 'create', no precedent appsetting update commands + _set_remote_or_local_git(cmd, webapp, resource_group_name, name, deployment_source_url, + deployment_source_branch, deployment_local_git) + + _fill_ftp_publishing_url(cmd, webapp, resource_group_name, name) + + if assign_identities is not None: + identity = assign_identity(cmd, resource_group_name, name, assign_identities, + role, None, scope) + webapp.identity = identity + + return webapp + + +def scale_webapp(cmd, resource_group_name, name, instance_count, slot=None): + return update_site_configs(cmd, resource_group_name, name, + number_of_workers=instance_count, slot=slot) + + +def show_webapp(cmd, resource_group_name, name, slot=None, app_instance=None): + webapp = app_instance + if not app_instance: # when the routine is invoked as a help method, not through commands + webapp = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get', slot) + if not webapp: + raise ResourceNotFoundError("WebApp'{}', is not found on RG '{}'.".format(name, resource_group_name)) + webapp.site_config = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'get_configuration', slot) + _rename_server_farm_props(webapp) + + # TODO: get rid of this conditional once the api's are implemented for kubeapps + if KUBE_APP_KIND.lower() not in webapp.kind.lower(): + _fill_ftp_publishing_url(cmd, webapp, resource_group_name, name, slot) + + return webapp + + +def _is_function_kube(custom_location, plan_info, SkuDescription): + return custom_location or plan_info is not None and ( + plan_info.kind.upper() == KUBE_ASP_KIND.upper() or ( + isinstance(plan_info.sku, SkuDescription) and plan_info.sku.name.upper() == KUBE_DEFAULT_SKU)) + + +def create_function(cmd, resource_group_name, name, storage_account, plan=None, custom_location=None, + os_type=None, functions_version=None, runtime=None, runtime_version=None, + consumption_plan_location=None, app_insights=None, app_insights_key=None, + disable_app_insights=None, deployment_source_url=None, + deployment_source_branch='master', deployment_local_git=None, + docker_registry_server_password=None, docker_registry_server_user=None, + deployment_container_image_name=None, tags=None, + min_worker_count=None, max_worker_count=None): + # pylint: disable=too-many-statements, too-many-branches + SkuDescription = cmd.get_models('SkuDescription') + if functions_version is None: + logger.warning("No functions version specified so defaulting to 2. In the future, specifying a version will " + "be required. To create a 2.x function you would pass in the flag `--functions-version 2`") + functions_version = '2' + if deployment_source_url and deployment_local_git: + raise CLIError('usage error: --deployment-source-url | --deployment-local-git') + + if not plan and not consumption_plan_location and not custom_location: + raise RequiredArgumentMissingError("Either Plan, Consumption Plan or Custom Location must be specified") + + if consumption_plan_location and custom_location: + raise MutuallyExclusiveArgumentError("Consumption Plan and Custom Location cannot be used together") + + if consumption_plan_location and plan: + raise MutuallyExclusiveArgumentError("Consumption Plan and Plan cannot be used together") + + SiteConfig, Site, NameValuePair = cmd.get_models('SiteConfig', 'Site', 'NameValuePair') + docker_registry_server_url = parse_docker_image_name(deployment_container_image_name) + + site_config = SiteConfig(app_settings=[]) + functionapp_def = Site(location=None, site_config=site_config, tags=tags) + client = web_client_factory(cmd.cli_ctx) + plan_info = None + if runtime is not None: + runtime = runtime.lower() + + if consumption_plan_location: + locations = list_consumption_locations(cmd) + location = next((loc for loc in locations if loc['name'].lower() == consumption_plan_location.lower()), None) + if location is None: + raise CLIError("Location is invalid. Use: az functionapp list-consumption-locations") + functionapp_def.location = consumption_plan_location + functionapp_def.kind = 'functionapp' + # if os_type is None, the os type is windows + is_linux = os_type and os_type.lower() == 'linux' + + else: # apps with SKU based plan + _should_create_new_plan = _should_create_new_appservice_plan_for_k8se(cmd, + name, custom_location, + plan, resource_group_name) + if _should_create_new_plan: + plan = generate_default_app_service_plan_name(name) + logger.warning("Plan not specified. Creating Plan '%s' with sku '%s'", plan, KUBE_DEFAULT_SKU) + create_app_service_plan(cmd=cmd, resource_group_name=resource_group_name, + name=plan, is_linux=True, hyper_v=False, custom_location=custom_location, + per_site_scaling=True, number_of_workers=1) + + if custom_location and plan: + if not _validate_asp_and_custom_location_kube_envs_match(cmd, resource_group_name, custom_location, plan): + raise ValidationError("Custom location's kube environment " + "and App Service Plan's kube environment don't match") + elif custom_location and not plan: + app_details = get_app_details(cmd, name, resource_group_name) + if app_details is not None: + plan = app_details.server_farm_id + + if is_valid_resource_id(plan): + parse_result = parse_resource_id(plan) + plan_info = client.app_service_plans.get(parse_result['resource_group'], parse_result['name']) + else: + plan_info = client.app_service_plans.get(resource_group_name, plan) + if not plan_info: + raise CLIError("The plan '{}' doesn't exist".format(plan)) + location = plan_info.location + is_linux = plan_info.reserved + functionapp_def.server_farm_id = plan_info.id + functionapp_def.location = location + + is_kube = _is_function_kube(custom_location, plan_info, SkuDescription) + + if is_kube: + if min_worker_count is not None: + site_config.number_of_workers = min_worker_count + + if max_worker_count is not None: + site_config.app_settings.append(NameValuePair(name='K8SE_APP_MAX_INSTANCE_COUNT', value=max_worker_count)) + + if is_linux and not runtime and (consumption_plan_location or not deployment_container_image_name): + raise CLIError( + "usage error: --runtime RUNTIME required for linux functions apps without custom image.") + + if runtime: + if is_linux and runtime not in LINUX_RUNTIMES: + raise CLIError("usage error: Currently supported runtimes (--runtime) in linux function apps are: {}." + .format(', '.join(LINUX_RUNTIMES))) + if not is_linux and runtime not in WINDOWS_RUNTIMES: + raise CLIError("usage error: Currently supported runtimes (--runtime) in windows function apps are: {}." + .format(', '.join(WINDOWS_RUNTIMES))) + site_config.app_settings.append(NameValuePair(name='FUNCTIONS_WORKER_RUNTIME', value=runtime)) + + if runtime_version is not None: + if runtime is None: + raise CLIError('Must specify --runtime to use --runtime-version') + allowed_versions = FUNCTIONS_VERSION_TO_SUPPORTED_RUNTIME_VERSIONS[functions_version][runtime] + if runtime_version not in allowed_versions: + if runtime == 'dotnet': + raise CLIError('--runtime-version is not supported for --runtime dotnet. Dotnet version is determined ' + 'by --functions-version. Dotnet version {} is not supported by Functions version {}.' + .format(runtime_version, functions_version)) + raise CLIError('--runtime-version {} is not supported for the selected --runtime {} and ' + '--functions-version {}. Supported versions are: {}.' + .format(runtime_version, runtime, functions_version, ', '.join(allowed_versions))) + if runtime == 'dotnet': + logger.warning('--runtime-version is not supported for --runtime dotnet. Dotnet version is determined by ' + '--functions-version. Dotnet version will be %s for this function app.', + FUNCTIONS_VERSION_TO_DEFAULT_RUNTIME_VERSION[functions_version][runtime]) + + con_string = _validate_and_get_connection_string(cmd.cli_ctx, resource_group_name, storage_account) + + if is_kube: + functionapp_def.enable_additional_properties_sending() + # if Custom Location provided, use that for Extended Location Envelope. Otherwise, get Custom Location from ASP + if custom_location: + custom_location_id = _get_custom_location_id_from_custom_location(cmd, custom_location, resource_group_name) + if custom_location_id: + extended_loc = {'name': custom_location_id, 'type': 'CustomLocation'} + functionapp_def.additional_properties["extendedLocation"] = extended_loc + else: + extended_loc = plan_info.additional_properties["extendedLocation"] + functionapp_def.additional_properties["extendedLocation"] = extended_loc + + functionapp_def.kind = KUBE_FUNCTION_APP_KIND + functionapp_def.reserved = True + site_config.app_settings.append(NameValuePair(name='WEBSITES_PORT', value='80')) + site_config.app_settings.append(NameValuePair(name='MACHINEKEY_DecryptionKey', + value=str(hexlify(urandom(32)).decode()).upper())) + if deployment_container_image_name: + functionapp_def.kind = KUBE_FUNCTION_CONTAINER_APP_KIND + site_config.app_settings.append(NameValuePair(name='DOCKER_CUSTOM_IMAGE_NAME', + value=deployment_container_image_name)) + site_config.app_settings.append(NameValuePair(name='FUNCTION_APP_EDIT_MODE', value='readOnly')) + site_config.linux_fx_version = _format_fx_version(deployment_container_image_name) + site_config.app_settings.append(NameValuePair(name='DOCKER_REGISTRY_SERVER_URL', + value=docker_registry_server_url)) + if docker_registry_server_user is not None and docker_registry_server_password is not None: + site_config.app_settings.append(NameValuePair(name='DOCKER_REGISTRY_SERVER_USERNAME', + value=docker_registry_server_user)) + site_config.app_settings.append(NameValuePair(name='DOCKER_REGISTRY_SERVER_PASSWORD', + value=docker_registry_server_password)) + else: + site_config.app_settings.append(NameValuePair(name='WEBSITES_ENABLE_APP_SERVICE_STORAGE', value='true')) + site_config.linux_fx_version = _get_linux_fx_kube_functionapp(runtime, runtime_version) + elif is_linux: + functionapp_def.kind = 'functionapp,linux' + functionapp_def.reserved = True + is_consumption = consumption_plan_location is not None + if not is_consumption: + site_config.app_settings.append(NameValuePair(name='MACHINEKEY_DecryptionKey', + value=str(hexlify(urandom(32)).decode()).upper())) + if deployment_container_image_name: + functionapp_def.kind = 'functionapp,linux,container' + site_config.app_settings.append(NameValuePair(name='DOCKER_CUSTOM_IMAGE_NAME', + value=deployment_container_image_name)) + site_config.app_settings.append(NameValuePair(name='FUNCTION_APP_EDIT_MODE', value='readOnly')) + site_config.app_settings.append(NameValuePair(name='WEBSITES_ENABLE_APP_SERVICE_STORAGE', + value='false')) + site_config.linux_fx_version = _format_fx_version(deployment_container_image_name) + else: + site_config.app_settings.append(NameValuePair(name='WEBSITES_ENABLE_APP_SERVICE_STORAGE', + value='true')) + if runtime not in FUNCTIONS_VERSION_TO_SUPPORTED_RUNTIME_VERSIONS[functions_version]: + raise CLIError("An appropriate linux image for runtime:'{}', " + "functions_version: '{}' was not found".format(runtime, functions_version)) + if deployment_container_image_name is None: + site_config.linux_fx_version = _get_linux_fx_functionapp(functions_version, runtime, runtime_version) + else: + functionapp_def.kind = 'functionapp' + if runtime == "java": + site_config.java_version = _get_java_version_functionapp(functions_version, runtime_version) + + # adding appsetting to site to make it a function + site_config.app_settings.append(NameValuePair(name='FUNCTIONS_EXTENSION_VERSION', + value=_get_extension_version_functionapp(functions_version))) + site_config.app_settings.append(NameValuePair(name='AzureWebJobsStorage', value=con_string)) + site_config.app_settings.append(NameValuePair(name='AzureWebJobsDashboard', value=con_string)) + site_config.app_settings.append(NameValuePair(name='WEBSITE_NODE_DEFAULT_VERSION', + value=_get_website_node_version_functionapp(functions_version, + runtime, + runtime_version))) + + # If plan is not consumption or elastic premium, we need to set always on + if consumption_plan_location is None and not is_plan_elastic_premium(cmd, plan_info): + site_config.always_on = True + + # If plan is elastic premium or windows consumption, we need these app settings + is_windows_consumption = consumption_plan_location is not None and not is_linux + if is_plan_elastic_premium(cmd, plan_info) or is_windows_consumption: + site_config.app_settings.append(NameValuePair(name='WEBSITE_CONTENTAZUREFILECONNECTIONSTRING', + value=con_string)) + site_config.app_settings.append(NameValuePair(name='WEBSITE_CONTENTSHARE', value=name.lower())) + + create_app_insights = False + + if app_insights_key is not None: + site_config.app_settings.append(NameValuePair(name='APPINSIGHTS_INSTRUMENTATIONKEY', + value=app_insights_key)) + elif app_insights is not None: + instrumentation_key = get_app_insights_key(cmd.cli_ctx, resource_group_name, app_insights) + site_config.app_settings.append(NameValuePair(name='APPINSIGHTS_INSTRUMENTATIONKEY', + value=instrumentation_key)) + elif not disable_app_insights: + create_app_insights = True + + poller = client.web_apps.begin_create_or_update(resource_group_name, name, functionapp_def) + functionapp = LongRunningOperation(cmd.cli_ctx)(poller) + + if consumption_plan_location and is_linux: + logger.warning("Your Linux function app '%s', that uses a consumption plan has been successfully " + "created but is not active until content is published using " + "Azure Portal or the Functions Core Tools.", name) + else: + _set_remote_or_local_git(cmd, functionapp, resource_group_name, name, deployment_source_url, + deployment_source_branch, deployment_local_git) + + if create_app_insights: + try: + try_create_application_insights(cmd, functionapp) + except Exception: # pylint: disable=broad-except + logger.warning('Error while trying to create and configure an Application Insights for the Function App. ' + 'Please use the Azure Portal to create and configure the Application Insights, if needed.') + + if deployment_container_image_name: + update_container_settings_functionapp(cmd, resource_group_name, name, docker_registry_server_url, + deployment_container_image_name, docker_registry_server_user, + docker_registry_server_password) + + return functionapp + + +def update_container_settings_functionapp(cmd, resource_group_name, name, docker_registry_server_url=None, + docker_custom_image_name=None, docker_registry_server_user=None, + docker_registry_server_password=None, slot=None): + return update_container_settings(cmd, resource_group_name, name, docker_registry_server_url, + docker_custom_image_name, docker_registry_server_user, None, + docker_registry_server_password, multicontainer_config_type=None, + multicontainer_config_file=None, slot=slot) + + +def try_create_application_insights(cmd, functionapp): + creation_failed_warn = 'Unable to create the Application Insights for the Function App. ' \ + 'Please use the Azure Portal to manually create and configure the Application Insights, ' \ + 'if needed.' + + ai_resource_group_name = functionapp.resource_group + ai_name = functionapp.name + ai_location = functionapp.location + + app_insights_client = get_mgmt_service_client(cmd.cli_ctx, ApplicationInsightsManagementClient) + ai_properties = { + "name": ai_name, + "location": ai_location, + "kind": "web", + "properties": { + "Application_Type": "web" + } + } + appinsights = app_insights_client.components.create_or_update(ai_resource_group_name, ai_name, ai_properties) + if appinsights is None or appinsights.instrumentation_key is None: + logger.warning(creation_failed_warn) + return + + # We make this success message as a warning to no interfere with regular JSON output in stdout + logger.warning('Application Insights \"%s\" was created for this Function App. ' + 'You can visit https://portal.azure.com/#resource%s/overview to view your ' + 'Application Insights component', appinsights.name, appinsights.id) + + update_app_settings(cmd, functionapp.resource_group, functionapp.name, + ['APPINSIGHTS_INSTRUMENTATIONKEY={}'.format(appinsights.instrumentation_key)]) + + +# for any modifications to the non-optional parameters, adjust the reflection logic accordingly +# in the method +# pylint: disable=unused-argument +def update_site_configs(cmd, resource_group_name, name, slot=None, number_of_workers=None, linux_fx_version=None, + windows_fx_version=None, pre_warmed_instance_count=None, php_version=None, + python_version=None, net_framework_version=None, + java_version=None, java_container=None, java_container_version=None, + remote_debugging_enabled=None, web_sockets_enabled=None, + always_on=None, auto_heal_enabled=None, + use32_bit_worker_process=None, + min_tls_version=None, + http20_enabled=None, + app_command_line=None, + ftps_state=None, + generic_configurations=None): + configs = get_site_configs(cmd, resource_group_name, name, slot) + if number_of_workers is not None: + number_of_workers = validate_range_of_int_flag('--number-of-workers', number_of_workers, min_val=0, max_val=20) + if linux_fx_version: + if linux_fx_version.strip().lower().startswith('docker|'): + update_app_settings(cmd, resource_group_name, name, ["WEBSITES_ENABLE_APP_SERVICE_STORAGE=false"]) + else: + delete_app_settings(cmd, resource_group_name, name, ["WEBSITES_ENABLE_APP_SERVICE_STORAGE"]) + + if pre_warmed_instance_count is not None: + pre_warmed_instance_count = validate_range_of_int_flag('--prewarmed-instance-count', pre_warmed_instance_count, + min_val=0, max_val=20) + import inspect + frame = inspect.currentframe() + bool_flags = ['remote_debugging_enabled', 'web_sockets_enabled', 'always_on', + 'auto_heal_enabled', 'use32_bit_worker_process', 'http20_enabled'] + int_flags = ['pre_warmed_instance_count', 'number_of_workers'] + # note: getargvalues is used already in azure.cli.core.commands. + # and no simple functional replacement for this deprecating method for 3.5 + args, _, _, values = inspect.getargvalues(frame) # pylint: disable=deprecated-method + + for arg in args[3:]: + if arg in int_flags and values[arg] is not None: + values[arg] = validate_and_convert_to_int(arg, values[arg]) + if arg != 'generic_configurations' and values.get(arg, None): + setattr(configs, arg, values[arg] if arg not in bool_flags else values[arg] == 'true') + + generic_configurations = generic_configurations or [] + + # https://github.com/Azure/azure-cli/issues/14857 + updating_ip_security_restrictions = False + + result = {} + for s in generic_configurations: + try: + json_object = get_json_object(s) + for config_name in json_object: + if config_name.lower() == 'ip_security_restrictions': + updating_ip_security_restrictions = True + result.update(json_object) + except CLIError: + config_name, value = s.split('=', 1) + result[config_name] = value + + for config_name, value in result.items(): + if config_name.lower() == 'ip_security_restrictions': + updating_ip_security_restrictions = True + setattr(configs, config_name, value) + + if not updating_ip_security_restrictions: + setattr(configs, 'ip_security_restrictions', None) + + return _generic_site_operation(cmd.cli_ctx, resource_group_name, name, 'update_configuration', slot, configs) + + +def config_source_control(cmd, resource_group_name, name, repo_url, repository_type='git', branch=None, # pylint: disable=too-many-locals + manual_integration=None, git_token=None, slot=None): + client = web_client_factory(cmd.cli_ctx) + location = _get_location_from_webapp(client, resource_group_name, name) + + from azure.mgmt.web.models import SiteSourceControl, SourceControl + if git_token: + sc = SourceControl(location=location, source_control_name='GitHub', token=git_token) + client.update_source_control('GitHub', sc) + + source_control = SiteSourceControl(location=location, repo_url=repo_url, branch=branch, + is_manual_integration=manual_integration, + is_mercurial=(repository_type != 'git')) + + # SCC config can fail if previous commands caused SCMSite shutdown, so retry here. + for i in range(5): + try: + poller = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, + 'create_or_update_source_control', + slot, source_control) + return LongRunningOperation(cmd.cli_ctx)(poller) + except Exception as ex: # pylint: disable=broad-except + import re + ex = ex_handler_factory(no_throw=True)(ex) + # for non server errors(50x), just throw; otherwise retry 4 times + if i == 4 or not re.findall(r'\(50\d\)', str(ex)): + raise + logger.warning('retrying %s/4', i + 1) + time.sleep(5) # retry in a moment + return None + + +def list_publish_profiles(cmd, resource_group_name, name, slot=None, xml=False): + import xmltodict + + content = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, + 'list_publishing_profile_xml_with_secrets', slot, {"format": "WebDeploy"}) + full_xml = '' + for f in content: + full_xml += f.decode() + + if not xml: + profiles = xmltodict.parse(full_xml, xml_attribs=True)['publishData']['publishProfile'] + converted = [] + + if not isinstance(profiles, list): + profiles = [profiles] + + for profile in profiles: + new = {} + for key in profile: + # strip the leading '@' xmltodict put in for attributes + new[key.lstrip('@')] = profile[key] + converted.append(new) + return converted + cmd.cli_ctx.invocation.data['output'] = 'tsv' + return full_xml + + +# private helpers + +def _resolve_kube_environment_id(cli_ctx, kube_environment, resource_group_name): + if is_valid_resource_id(kube_environment): + return kube_environment + + from msrestazure.tools import resource_id + return resource_id( + subscription=get_subscription_id(cli_ctx), + resource_group=resource_group_name, + namespace='Microsoft.Web', + type='kubeEnvironments', + name=kube_environment) + + +def _get_linux_fx_functionapp(functions_version, runtime, runtime_version): + if runtime_version is None: + runtime_version = FUNCTIONS_VERSION_TO_DEFAULT_RUNTIME_VERSION[functions_version][runtime] + if runtime == 'dotnet': + runtime_version = DOTNET_RUNTIME_VERSION_TO_DOTNET_LINUX_FX_VERSION[runtime_version] + else: + runtime = runtime.upper() + return '{}|{}'.format(runtime, runtime_version) + + +def _get_linux_fx_kube_functionapp(runtime, runtime_version): + if runtime.upper() == "DOTNET": + runtime = "DOTNETCORE" + return '{}|{}'.format(runtime.upper(), runtime_version) + + +def _get_website_node_version_functionapp(functions_version, runtime, runtime_version): + if runtime is None or runtime != 'node': + return FUNCTIONS_VERSION_TO_DEFAULT_NODE_VERSION[functions_version] + if runtime_version is not None: + return '~{}'.format(runtime_version) + return FUNCTIONS_VERSION_TO_DEFAULT_NODE_VERSION[functions_version] + + +def _get_java_version_functionapp(functions_version, runtime_version): + if runtime_version is None: + runtime_version = FUNCTIONS_VERSION_TO_DEFAULT_RUNTIME_VERSION[functions_version]['java'] + if runtime_version == '8': + return '1.8' + return runtime_version + + +def _set_remote_or_local_git(cmd, webapp, resource_group_name, name, deployment_source_url=None, + deployment_source_branch='master', deployment_local_git=None): + if deployment_source_url: + logger.warning("Linking to git repository '%s'", deployment_source_url) + try: + config_source_control(cmd, resource_group_name, name, deployment_source_url, 'git', + deployment_source_branch, manual_integration=True) + except Exception as ex: # pylint: disable=broad-except + ex = ex_handler_factory(no_throw=True)(ex) + logger.warning("Link to git repository failed due to error '%s'", ex) + + if deployment_local_git: + local_git_info = enable_local_git(cmd, resource_group_name, name) + logger.warning("Local git is configured with url of '%s'", local_git_info['url']) + setattr(webapp, 'deploymentLocalGitUrl', local_git_info['url']) + + +def _add_fx_version(cmd, resource_group_name, name, custom_image_name, slot=None): + fx_version = _format_fx_version(custom_image_name) + web_app = get_webapp(cmd, resource_group_name, name, slot) + if not web_app: + raise CLIError("'{}' app doesn't exist in resource group {}".format(name, resource_group_name)) + linux_fx = fx_version if (web_app.reserved or not web_app.is_xenon) else None + windows_fx = fx_version if web_app.is_xenon else None + return update_site_configs(cmd, resource_group_name, name, + linux_fx_version=linux_fx, windows_fx_version=windows_fx, slot=slot) + + +@retryable_method(3, 5) +def _get_app_settings_from_scm(cmd, resource_group_name, name, slot=None): + scm_url = _get_scm_url(cmd, resource_group_name, name, slot) + settings_url = '{}/api/settings'.format(scm_url) + username, password = _get_site_credential(cmd.cli_ctx, resource_group_name, name, slot) + headers = { + 'Content-Type': 'application/octet-stream', + 'Cache-Control': 'no-cache', + 'User-Agent': get_az_user_agent() + } + + import requests + response = requests.get(settings_url, headers=headers, auth=(username, password), timeout=3) + + return response.json() or {} + + +# Check if the app setting is propagated to the Kudu site correctly by calling api/settings endpoint +# should_have [] is a list of app settings which are expected to be set +# should_not_have [] is a list of app settings which are expected to be absent +# should_contain {} is a dictionary of app settings which are expected to be set with precise values +# Return True if validation succeeded +def validate_app_settings_in_scm(cmd, resource_group_name, name, slot=None, + should_have=None, should_not_have=None, should_contain=None): + scm_settings = _get_app_settings_from_scm(cmd, resource_group_name, name, slot) + scm_setting_keys = set(scm_settings.keys()) + + if should_have and not set(should_have).issubset(scm_setting_keys): + return False + + if should_not_have and set(should_not_have).intersection(scm_setting_keys): + return False + + temp_setting = scm_settings.copy() + temp_setting.update(should_contain or {}) + if temp_setting != scm_settings: + return False + + return True + + +def remove_remote_build_app_settings(cmd, resource_group_name, name, slot): + settings = get_app_settings(cmd, resource_group_name, name, slot) + scm_do_build_during_deployment = None + + app_settings_should_contain = {} + + for keyval in settings: + value = keyval['value'].lower() + if keyval['name'] == 'SCM_DO_BUILD_DURING_DEPLOYMENT': + scm_do_build_during_deployment = value in ('true', '1') + + if scm_do_build_during_deployment is not False: + logger.warning("Setting SCM_DO_BUILD_DURING_DEPLOYMENT to false") + update_app_settings(cmd, resource_group_name, name, [ + "SCM_DO_BUILD_DURING_DEPLOYMENT=false" + ], slot) + app_settings_should_contain['SCM_DO_BUILD_DURING_DEPLOYMENT'] = 'false' + + # Wait for scm site to get the latest app settings + if app_settings_should_contain: + logger.warning("Waiting SCM site to be updated with the latest app settings") + scm_is_up_to_date = False + retries = 10 + while not scm_is_up_to_date and retries >= 0: + scm_is_up_to_date = validate_app_settings_in_scm( + cmd, resource_group_name, name, slot, + should_contain=app_settings_should_contain) + retries -= 1 + time.sleep(5) + + if retries < 0: + logger.warning("App settings may not be propagated to the SCM site") + + +def add_remote_build_app_settings(cmd, resource_group_name, name, slot): + settings = get_app_settings(cmd, resource_group_name, name, slot) + scm_do_build_during_deployment = None + website_run_from_package = None + enable_oryx_build = None + + app_settings_should_not_have = [] + app_settings_should_contain = {} + + for keyval in settings: + value = keyval['value'].lower() + if keyval['name'] == 'SCM_DO_BUILD_DURING_DEPLOYMENT': + scm_do_build_during_deployment = value in ('true', '1') + if keyval['name'] == 'WEBSITE_RUN_FROM_PACKAGE': + website_run_from_package = value + if keyval['name'] == 'ENABLE_ORYX_BUILD': + enable_oryx_build = value + + if scm_do_build_during_deployment is not True: + logger.warning("Setting SCM_DO_BUILD_DURING_DEPLOYMENT to true") + update_app_settings(cmd, resource_group_name, name, [ + "SCM_DO_BUILD_DURING_DEPLOYMENT=true" + ], slot) + app_settings_should_contain['SCM_DO_BUILD_DURING_DEPLOYMENT'] = 'true' + + if website_run_from_package: + logger.warning("Removing WEBSITE_RUN_FROM_PACKAGE app setting") + delete_app_settings(cmd, resource_group_name, name, [ + "WEBSITE_RUN_FROM_PACKAGE" + ], slot) + app_settings_should_not_have.append('WEBSITE_RUN_FROM_PACKAGE') + + if enable_oryx_build: + logger.warning("Removing ENABLE_ORYX_BUILD app setting") + delete_app_settings(cmd, resource_group_name, name, [ + "ENABLE_ORYX_BUILD" + ], slot) + app_settings_should_not_have.append('ENABLE_ORYX_BUILD') + + # Wait for scm site to get the latest app settings + if app_settings_should_not_have or app_settings_should_contain: + logger.warning("Waiting SCM site to be updated with the latest app settings") + scm_is_up_to_date = False + retries = 10 + while not scm_is_up_to_date and retries >= 0: + scm_is_up_to_date = validate_app_settings_in_scm( + cmd, resource_group_name, name, slot, + should_contain=app_settings_should_contain, + should_not_have=app_settings_should_not_have) + retries -= 1 + time.sleep(5) + + if retries < 0: + logger.warning("App settings may not be propagated to the SCM site.") + + +def enable_zip_deploy_functionapp(cmd, resource_group_name, name, src, build_remote=False, timeout=None, slot=None): + client = web_client_factory(cmd.cli_ctx) + app = client.web_apps.get(resource_group_name, name) + if app is None: + raise CLIError('The function app \'{}\' was not found in resource group \'{}\'. ' + 'Please make sure these values are correct.'.format(name, resource_group_name)) + parse_plan_id = parse_resource_id(app.server_farm_id) + plan_info = None + retry_delay = 10 # seconds + # We need to retry getting the plan because sometimes if the plan is created as part of function app, + # it can take a couple of tries before it gets the plan + for _ in range(5): + plan_info = client.app_service_plans.get(parse_plan_id['resource_group'], + parse_plan_id['name']) + if plan_info is not None: + break + time.sleep(retry_delay) + + if build_remote and not app.reserved: + raise CLIError('Remote build is only available on Linux function apps') + + is_consumption = is_plan_consumption(cmd, plan_info) + if (not build_remote) and is_consumption and app.reserved: + return upload_zip_to_storage(cmd, resource_group_name, name, src, slot) + if build_remote: + add_remote_build_app_settings(cmd, resource_group_name, name, slot) + else: + remove_remote_build_app_settings(cmd, resource_group_name, name, slot) + + return enable_zip_deploy(cmd, resource_group_name, name, src, timeout, slot) + + +def enable_zip_deploy_webapp(cmd, resource_group_name, name, src, timeout=None, slot=None, is_kube=False): + return enable_zip_deploy(cmd, resource_group_name, name, src, timeout=timeout, slot=slot, is_kube=is_kube) + + +def enable_zip_deploy(cmd, resource_group_name, name, src, timeout=None, slot=None, is_kube=False): + logger.warning("Getting scm site credentials for zip deployment") + user_name, password = _get_site_credential(cmd.cli_ctx, resource_group_name, name, slot) + # Wait for a few seconds for envoy changes to propogate, for a kube app + if is_kube: + time.sleep(7) + try: + scm_url = _get_scm_url(cmd, resource_group_name, name, slot) + except ValueError as e: + raise CLIError('Failed to fetch scm url for function app') from e + + zip_url = scm_url + '/api/zipdeploy?isAsync=true' + deployment_status_url = scm_url + '/api/deployments/latest' + + import urllib3 + authorization = urllib3.util.make_headers(basic_auth='{0}:{1}'.format(user_name, password)) + headers = authorization + headers['Content-Type'] = 'application/octet-stream' + headers['Cache-Control'] = 'no-cache' + headers['User-Agent'] = get_az_user_agent() + + import requests + import os + from azure.cli.core.util import should_disable_connection_verify + # Read file content + with open(os.path.realpath(os.path.expanduser(src)), 'rb') as fs: + zip_content = fs.read() + logger.warning("Starting zip deployment. This operation can take a while to complete ...") + res = requests.post(zip_url, data=zip_content, headers=headers, verify=not should_disable_connection_verify()) + logger.warning("Deployment endpoint responded with status code %d", res.status_code) + + if is_kube and res.status_code != 202 and res.status_code != 409: + logger.warning('Something went wrong. It may take a few seconds for a new deployment to reflect' + 'on kube cluster. Retrying deployment...') + time.sleep(10) # retry in a moment + res = requests.post(zip_url, data=zip_content, headers=headers, + verify=not should_disable_connection_verify()) + logger.warning("Deployment endpoint responded with status code %d", res.status_code) + + # check if there's an ongoing process + if res.status_code == 409: + raise CLIError("There may be an ongoing deployment or your app setting has WEBSITE_RUN_FROM_PACKAGE. " + "Please track your deployment in {} and ensure the WEBSITE_RUN_FROM_PACKAGE app setting " + "is removed.".format(deployment_status_url)) + + # check the status of async deployment + response = _check_zip_deployment_status(cmd, resource_group_name, name, deployment_status_url, + authorization, timeout) + return response + + +def _get_scm_url(cmd, resource_group_name, name, slot=None): + from azure.mgmt.web.models import HostType + webapp = show_webapp(cmd, resource_group_name, name, slot=slot) + for host in webapp.host_name_ssl_states or []: + if host.host_type == HostType.repository: + return "https://{}".format(host.name) + + # this should not happen, but throw anyway + raise ValueError('Failed to retrieve Scm Uri') + + +def restart_webapp(cmd, resource_group_name, name, slot=None): + return WebAppClient.restart(cmd=cmd, resource_group_name=resource_group_name, name=name, slot=slot) + + +def _check_zip_deployment_status(cmd, rg_name, name, deployment_status_url, authorization, timeout=None): + import requests + from azure.cli.core.util import should_disable_connection_verify + total_trials = (int(timeout) // 2) if timeout else 450 + num_trials = 0 + while num_trials < total_trials: + time.sleep(2) + response = requests.get(deployment_status_url, headers=authorization, + verify=not should_disable_connection_verify()) + try: + res_dict = response.json() + except json.decoder.JSONDecodeError: + logger.warning("Deployment status endpoint %s returns malformed data. Retrying...", deployment_status_url) + res_dict = {} + finally: + num_trials = num_trials + 1 + + if res_dict.get('status', 0) == 3: + _configure_default_logging(cmd, rg_name, name) + raise CLIError("""Zip deployment failed. {}. Please run the command az webapp log tail + -n {} -g {}""".format(res_dict, name, rg_name)) + if res_dict.get('status', 0) == 4: + break + if 'progress' in res_dict: + logger.info(res_dict['progress']) # show only in debug mode, customers seem to find this confusing + # if the deployment is taking longer than expected + if res_dict.get('status', 0) != 4: + _configure_default_logging(cmd, rg_name, name) + raise CLIError("""Timeout reached by the command, however, the deployment operation + is still on-going. Navigate to your scm site to check the deployment status""") + return res_dict + + +def _fill_ftp_publishing_url(cmd, webapp, resource_group_name, name, slot=None): + profiles = list_publish_profiles(cmd, resource_group_name, name, slot) + try: + url = next((p['publishUrl'] for p in profiles if p['publishMethod'] == 'FTP'), None) + setattr(webapp, 'ftpPublishingUrl', url) + except StopIteration: + pass + + return webapp diff --git a/src/appservice-kube/azext_appservice_kube/getfunctionsjson.sh b/src/appservice-kube/azext_appservice_kube/getfunctionsjson.sh new file mode 100644 index 00000000000..096da220520 --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/getfunctionsjson.sh @@ -0,0 +1,29 @@ +#! /bin/sh +if [ -z "$AzureWebJobsScriptRoot" ]; then + cd /home/site/wwwroot +else + cd "$AzureWebJobsScriptRoot" +fi + +echo '{' +echo '"hostJson":' +if [ -f "host.json" ]; then + cat host.json +else + echo '{ }' +fi + +echo ',' + +echo '"functionsJson": {' + +for d in */; do + d=$(echo $d | tr -d '/') + if [ -f "${d}/function.json" ]; then + echo "\"${d}\": " + cat "${d}/function.json" + echo ',' + fi +done +echo '}' +echo '}' \ No newline at end of file diff --git a/src/appservice-kube/azext_appservice_kube/tests/__init__.py b/src/appservice-kube/azext_appservice_kube/tests/__init__.py new file mode 100644 index 00000000000..99c0f28cd71 --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/tests/__init__.py @@ -0,0 +1,5 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- diff --git a/src/appservice-kube/azext_appservice_kube/tests/latest/__init__.py b/src/appservice-kube/azext_appservice_kube/tests/latest/__init__.py new file mode 100644 index 00000000000..99c0f28cd71 --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/tests/latest/__init__.py @@ -0,0 +1,5 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- diff --git a/src/appservice-kube/azext_appservice_kube/tests/latest/test_appservice_kube_scenario.py b/src/appservice-kube/azext_appservice_kube/tests/latest/test_appservice_kube_scenario.py new file mode 100644 index 00000000000..98fd540dfd0 --- /dev/null +++ b/src/appservice-kube/azext_appservice_kube/tests/latest/test_appservice_kube_scenario.py @@ -0,0 +1,17 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import unittest +import base64 + +from azure_devtools.scenario_tests import AllowLargeResponse +from azure.cli.testsdk import (ScenarioTest, ResourceGroupPreparer, RoleBasedServicePrincipalPreparer, live_only) + +TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) + +# TODO +class AppserviceKubernetesScenarioTest(ScenarioTest): + pass \ No newline at end of file diff --git a/src/appservice-kube/setup.cfg b/src/appservice-kube/setup.cfg new file mode 100644 index 00000000000..3c6e79cf31d --- /dev/null +++ b/src/appservice-kube/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/src/appservice-kube/setup.py b/src/appservice-kube/setup.py new file mode 100644 index 00000000000..b857d57665d --- /dev/null +++ b/src/appservice-kube/setup.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +from codecs import open +from setuptools import setup, find_packages +try: + from azure_bdist_wheel import cmdclass +except ImportError: + from distutils import log as logger + logger.warn("Wheel is not available, disabling bdist_wheel hook") + +# TODO: Confirm this is the right version number you want and it matches your +# HISTORY.rst entry. +VERSION = '0.1.0' + +# The full list of classifiers is available at +# https://pypi.python.org/pypi?%3Aaction=list_classifiers +CLASSIFIERS = [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'License :: OSI Approved :: MIT License', +] + +# TODO: Add any additional SDK dependencies here +DEPENDENCIES = [] + +with open('README.rst', 'r', encoding='utf-8') as f: + README = f.read() +with open('HISTORY.rst', 'r', encoding='utf-8') as f: + HISTORY = f.read() + +setup( + name='appservice-kube', + version=VERSION, + description='Microsoft Azure Command-Line Tools App Service on Kubernetes Extension', + # TODO: Update author and email, if applicable + author='Microsoft Corporation', + author_email='azpycli@microsoft.com', + # TODO: consider pointing directly to your source code instead of the generic repo + url='https://github.com/Azure/azure-cli-extensions', + long_description=README + '\n\n' + HISTORY, + license='MIT', + classifiers=CLASSIFIERS, + packages=find_packages(), + install_requires=DEPENDENCIES, + scripts=['azext_appservice_kube/getfunctionsjson.sh'], + package_data={'azext_appservice_kube': ['azext_metadata.json']}, +) diff --git a/src/service_name.json b/src/service_name.json index b3da68d67fb..96caa7cab44 100644 --- a/src/service_name.json +++ b/src/service_name.json @@ -19,6 +19,11 @@ "AzureServiceName": "Azure CLI", "URL": "" }, + { + "Command": "az appservice", + "AzureServiceName": "Azure App Service", + "URL": "https://docs.microsoft.com/azure/app-service/" + }, { "Command": "az attestation", "AzureServiceName": "Azure Attestation", @@ -159,6 +164,11 @@ "AzureServiceName": "Azure Monitor", "URL": "" }, + { + "Command": "az functionapp", + "AzureServiceName": "Azure Functions", + "URL": "https://docs.microsoft.com/azure/azure-functions/" + }, { "Command": "az fzf", "AzureServiceName": "Azure CLI", @@ -448,12 +458,12 @@ "Command": "az arcdata", "AzureServiceName": "Azure Arc", "URL": "https://docs.microsoft.com/en-us/azure/azure-arc/data/" - }, + }, { "Command": "az arcappliance", "AzureServiceName": "Azure Arc", "URL": "https://docs.microsoft.com/en-us/azure/azure-arc/" - }, + }, { "Command": "az customlocation", "AzureServiceName": "Azure Arc",