1# -------------------------------------------------------------------------------------------- 2# Copyright (c) Microsoft Corporation. All rights reserved. 3# Licensed under the MIT License. See License.txt in the project root for license information. 4# -------------------------------------------------------------------------------------------- 5 6import os 7import zipfile 8from knack.util import CLIError 9from knack.log import get_logger 10from azure.cli.core.commands.client_factory import get_mgmt_service_client 11from azure.cli.core.util import get_file_json 12from azure.mgmt.web.models import SkuDescription 13 14from ._constants import (NETCORE_VERSION_DEFAULT, NETCORE_VERSIONS, NODE_VERSION_DEFAULT, 15 NODE_VERSIONS, NETCORE_RUNTIME_NAME, NODE_RUNTIME_NAME, ASPDOTNET_RUNTIME_NAME, 16 ASPDOTNET_VERSION_DEFAULT, DOTNET_VERSIONS, STATIC_RUNTIME_NAME, 17 PYTHON_RUNTIME_NAME, PYTHON_VERSION_DEFAULT, LINUX_SKU_DEFAULT, OS_DEFAULT, 18 NODE_VERSION_NEWER, DOTNET_RUNTIME_NAME, DOTNET_VERSION_DEFAULT, 19 DOTNET_TARGET_FRAMEWORK_STRING, GENERATE_RANDOM_APP_NAMES) 20 21logger = get_logger(__name__) 22 23 24def _resource_client_factory(cli_ctx, **_): 25 from azure.cli.core.profiles import ResourceType 26 return get_mgmt_service_client(cli_ctx, ResourceType.MGMT_RESOURCE_RESOURCES) 27 28 29def web_client_factory(cli_ctx, **_): 30 from azure.mgmt.web import WebSiteManagementClient 31 return get_mgmt_service_client(cli_ctx, WebSiteManagementClient) 32 33 34def zip_contents_from_dir(dirPath, lang): 35 import tempfile 36 import uuid 37 relroot = os.path.abspath(tempfile.gettempdir()) 38 path_and_file = os.path.splitdrive(dirPath)[1] 39 file_val = os.path.split(path_and_file)[1] 40 file_val_unique = file_val + str(uuid.uuid4())[:259] 41 zip_file_path = relroot + os.path.sep + file_val_unique + ".zip" 42 abs_src = os.path.abspath(dirPath) 43 try: 44 with zipfile.ZipFile("{}".format(zip_file_path), "w", zipfile.ZIP_DEFLATED) as zf: 45 for dirname, subdirs, files in os.walk(dirPath): 46 # skip node_modules folder for Node apps, 47 # since zip_deployment will perform the build operation 48 if lang.lower() == NODE_RUNTIME_NAME: 49 subdirs[:] = [d for d in subdirs if 'node_modules' not in d] 50 elif lang.lower() == NETCORE_RUNTIME_NAME: 51 subdirs[:] = [d for d in subdirs if d not in ['obj', 'bin']] 52 elif lang.lower() == PYTHON_RUNTIME_NAME: 53 subdirs[:] = [d for d in subdirs if 'env' not in d] # Ignores dir that contain env 54 55 filtered_files = [] 56 for filename in files: 57 if filename == '.env': 58 logger.info("Skipping file: %s/%s", dirname, filename) 59 else: 60 filtered_files.append(filename) 61 files[:] = filtered_files 62 63 for filename in files: 64 absname = os.path.abspath(os.path.join(dirname, filename)) 65 arcname = absname[len(abs_src) + 1:] 66 zf.write(absname, arcname) 67 except IOError as e: 68 if e.errno == 13: 69 raise CLIError('Insufficient permissions to create a zip in current directory. ' 70 'Please re-run the command with administrator privileges') 71 raise CLIError(e) 72 73 return zip_file_path 74 75 76def get_runtime_version_details(file_path, lang_name): 77 version_detected = None 78 version_to_create = None 79 if lang_name.lower() == DOTNET_RUNTIME_NAME: 80 version_detected = DOTNET_VERSION_DEFAULT 81 version_to_create = DOTNET_VERSION_DEFAULT 82 elif lang_name.lower() == NETCORE_RUNTIME_NAME: 83 # method returns list in DESC, pick the first 84 version_detected = parse_netcore_version(file_path)[0] 85 version_to_create = detect_netcore_version_tocreate(version_detected) 86 elif lang_name.lower() == ASPDOTNET_RUNTIME_NAME: 87 # method returns list in DESC, pick the first 88 version_detected = parse_dotnet_version(file_path) 89 version_to_create = detect_dotnet_version_tocreate(version_detected) 90 elif lang_name.lower() == NODE_RUNTIME_NAME: 91 if file_path == '': 92 version_detected = "-" 93 version_to_create = NODE_VERSION_DEFAULT 94 else: 95 version_detected = parse_node_version(file_path)[0] 96 version_to_create = detect_node_version_tocreate(version_detected) 97 elif lang_name.lower() == PYTHON_RUNTIME_NAME: 98 version_detected = "-" 99 version_to_create = PYTHON_VERSION_DEFAULT 100 elif lang_name.lower() == STATIC_RUNTIME_NAME: 101 version_detected = "-" 102 version_to_create = "-" 103 return {'detected': version_detected, 'to_create': version_to_create} 104 105 106def create_resource_group(cmd, rg_name, location): 107 from azure.cli.core.profiles import ResourceType, get_sdk 108 rcf = _resource_client_factory(cmd.cli_ctx) 109 resource_group = get_sdk(cmd.cli_ctx, ResourceType.MGMT_RESOURCE_RESOURCES, 'ResourceGroup', mod='models') 110 rg_params = resource_group(location=location) 111 return rcf.resource_groups.create_or_update(rg_name, rg_params) 112 113 114def check_resource_group_exists(cmd, rg_name): 115 rcf = _resource_client_factory(cmd.cli_ctx) 116 return rcf.resource_groups.check_existence(rg_name) 117 118 119def _check_resource_group_supports_os(cmd, rg_name, is_linux): 120 # get all appservice plans from RG 121 client = web_client_factory(cmd.cli_ctx) 122 plans = list(client.app_service_plans.list_by_resource_group(rg_name)) 123 for item in plans: 124 # for Linux if an app with reserved==False exists, ASP doesn't support Linux 125 if is_linux and not item.reserved: 126 return False 127 if not is_linux and item.reserved: 128 return False 129 return True 130 131 132def get_num_apps_in_asp(cmd, rg_name, asp_name): 133 client = web_client_factory(cmd.cli_ctx) 134 return len(list(client.app_service_plans.list_web_apps(rg_name, asp_name))) 135 136 137# pylint:disable=unexpected-keyword-arg 138def get_lang_from_content(src_path, html=False): 139 # NODE: package.json should exist in the application root dir 140 # NETCORE & DOTNET: *.csproj should exist in the application dir 141 # NETCORE: <TargetFramework>netcoreapp2.0</TargetFramework> 142 # DOTNET: <TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion> 143 runtime_details_dict = dict.fromkeys(['language', 'file_loc', 'default_sku']) 144 package_json_file = os.path.join(src_path, 'package.json') 145 package_python_file = os.path.join(src_path, 'requirements.txt') 146 static_html_file = "" 147 package_netcore_file = "" 148 runtime_details_dict['language'] = '' 149 runtime_details_dict['file_loc'] = '' 150 runtime_details_dict['default_sku'] = 'F1' 151 import fnmatch 152 for _dirpath, _dirnames, files in os.walk(src_path): 153 for file in files: 154 if html and (fnmatch.fnmatch(file, "*.html") or fnmatch.fnmatch(file, "*.htm") or 155 fnmatch.fnmatch(file, "*shtml.")): 156 static_html_file = os.path.join(src_path, file) 157 break 158 if fnmatch.fnmatch(file, "*.csproj"): 159 package_netcore_file = os.path.join(src_path, file) 160 break 161 162 if html: 163 if static_html_file: 164 runtime_details_dict['language'] = STATIC_RUNTIME_NAME 165 runtime_details_dict['file_loc'] = static_html_file 166 runtime_details_dict['default_sku'] = 'F1' 167 else: 168 raise CLIError("The html flag was passed, but could not find HTML files, " 169 "see 'https://go.microsoft.com/fwlink/?linkid=2109470' for more information") 170 elif os.path.isfile(package_python_file): 171 runtime_details_dict['language'] = PYTHON_RUNTIME_NAME 172 runtime_details_dict['file_loc'] = package_python_file 173 runtime_details_dict['default_sku'] = LINUX_SKU_DEFAULT 174 elif os.path.isfile(package_json_file) or os.path.isfile('server.js') or os.path.isfile('index.js'): 175 runtime_details_dict['language'] = NODE_RUNTIME_NAME 176 runtime_details_dict['file_loc'] = package_json_file if os.path.isfile(package_json_file) else '' 177 runtime_details_dict['default_sku'] = LINUX_SKU_DEFAULT 178 elif package_netcore_file: 179 runtime_lang = detect_dotnet_lang(package_netcore_file) 180 runtime_details_dict['language'] = runtime_lang 181 runtime_details_dict['file_loc'] = package_netcore_file 182 runtime_details_dict['default_sku'] = 'F1' 183 else: # TODO: Update the doc when the detection logic gets updated 184 raise CLIError("Could not auto-detect the runtime stack of your app.\n" 185 "HINT: Are you in the right folder?\n" 186 "For more information, see 'https://go.microsoft.com/fwlink/?linkid=2109470'") 187 return runtime_details_dict 188 189 190def detect_dotnet_lang(csproj_path): 191 import xml.etree.ElementTree as ET 192 import re 193 parsed_file = ET.parse(csproj_path) 194 root = parsed_file.getroot() 195 version_lang = '' 196 version_full = '' 197 for target_ver in root.iter('TargetFramework'): 198 version_full = target_ver.text 199 version_full = ''.join(version_full.split()).lower() 200 version_lang = re.sub(r'([^a-zA-Z\s]+?)', '', target_ver.text) 201 202 if version_full and version_full.startswith(DOTNET_TARGET_FRAMEWORK_STRING): 203 return DOTNET_RUNTIME_NAME 204 if 'netcore' in version_lang.lower(): 205 return NETCORE_RUNTIME_NAME 206 return ASPDOTNET_RUNTIME_NAME 207 208 209def parse_dotnet_version(file_path): 210 version_detected = ['4.7'] 211 try: 212 from xml.dom import minidom 213 import re 214 xmldoc = minidom.parse(file_path) 215 framework_ver = xmldoc.getElementsByTagName('TargetFrameworkVersion') 216 target_ver = framework_ver[0].firstChild.data 217 non_decimal = re.compile(r'[^\d.]+') 218 # reduce the version to '5.7.4' from '5.7' 219 if target_ver is not None: 220 # remove the string from the beginning of the version value 221 c = non_decimal.sub('', target_ver) 222 version_detected = c[:3] 223 except: # pylint: disable=bare-except 224 version_detected = version_detected[0] 225 return version_detected 226 227 228def parse_netcore_version(file_path): 229 import xml.etree.ElementTree as ET 230 import re 231 version_detected = ['0.0'] 232 parsed_file = ET.parse(file_path) 233 root = parsed_file.getroot() 234 for target_ver in root.iter('TargetFramework'): 235 version_detected = re.findall(r"\d+\.\d+", target_ver.text) 236 # incase of multiple versions detected, return list in descending order 237 version_detected = sorted(version_detected, key=float, reverse=True) 238 return version_detected 239 240 241def parse_node_version(file_path): 242 # from node experts the node value in package.json can be found here "engines": { "node": ">=10.6.0"} 243 import json 244 import re 245 version_detected = [] 246 with open(file_path) as data_file: 247 data = json.load(data_file) 248 for key, value in data.items(): 249 if key == 'engines' and 'node' in value: 250 value_detected = value['node'] 251 non_decimal = re.compile(r'[^\d.]+') 252 # remove the string ~ or > that sometimes exists in version value 253 c = non_decimal.sub('', value_detected) 254 # reduce the version to '6.0' from '6.0.0' 255 if '.' in c: # handle version set as 4 instead of 4.0 256 num_array = c.split('.') 257 num = num_array[0] + "." + num_array[1] 258 else: 259 num = c + ".0" 260 version_detected.append(num) 261 return version_detected or ['0.0'] 262 263 264def detect_netcore_version_tocreate(detected_ver): 265 if detected_ver in NETCORE_VERSIONS: 266 return detected_ver 267 return NETCORE_VERSION_DEFAULT 268 269 270def detect_dotnet_version_tocreate(detected_ver): 271 min_ver = DOTNET_VERSIONS[0] 272 if detected_ver in DOTNET_VERSIONS: 273 return detected_ver 274 if detected_ver < min_ver: 275 return min_ver 276 return ASPDOTNET_VERSION_DEFAULT 277 278 279def detect_node_version_tocreate(detected_ver): 280 if detected_ver in NODE_VERSIONS: 281 return detected_ver 282 # get major version & get the closest version from supported list 283 major_ver = int(detected_ver.split('.')[0]) 284 node_ver = NODE_VERSION_DEFAULT 285 # TODO: Handle checking for minor versions if node major version is 10 286 if major_ver <= 11: 287 node_ver = NODE_VERSION_DEFAULT 288 else: 289 node_ver = NODE_VERSION_NEWER 290 return node_ver 291 292 293def find_key_in_json(json_data, key): 294 for k, v in json_data.items(): 295 if key in k: 296 yield v 297 elif isinstance(v, dict): 298 for id_val in find_key_in_json(v, key): 299 yield id_val 300 301 302def set_location(cmd, sku, location): 303 client = web_client_factory(cmd.cli_ctx) 304 if location is None: 305 locs = client.list_geo_regions(sku, True) 306 available_locs = [] 307 for loc in locs: 308 available_locs.append(loc.name) 309 loc = available_locs[0] 310 else: 311 loc = location 312 return loc.replace(" ", "").lower() 313 314 315def get_site_availability(cmd, name): 316 """ This is used by az webapp up to verify if a site needs to be created or should just be deployed""" 317 client = web_client_factory(cmd.cli_ctx) 318 availability = client.check_name_availability(name, 'Site') 319 320 # check for "." in app name. it is valid for hostnames to contain it, but not allowed for webapp names 321 if "." in name: 322 availability.name_available = False 323 availability.reason = "Invalid" 324 availability.message = ("Site names only allow alphanumeric characters and hyphens, " 325 "cannot start or end in a hyphen, and must be less than 64 chars.") 326 return availability 327 328 329def get_app_details(cmd, name): 330 client = web_client_factory(cmd.cli_ctx) 331 data = (list(filter(lambda x: name.lower() == x.name.lower(), client.web_apps.list()))) 332 _num_items = len(data) 333 if _num_items > 0: 334 return data[0] 335 return None 336 337 338def get_rg_to_use(user, loc, os_name, rg_name=None): 339 default_rg = "{}_rg_{}_{}".format(user, os_name, loc.replace(" ", "").lower()) 340 if rg_name is not None: 341 return rg_name 342 return default_rg 343 344 345def get_profile_username(): 346 from azure.cli.core._profile import Profile 347 user = Profile().get_current_account_user() 348 user = user.split('@', 1)[0] 349 if len(user.split('#', 1)) > 1: # on cloudShell user is in format live.com#user@domain.com 350 user = user.split('#', 1)[1] 351 return user 352 353 354def get_sku_to_use(src_dir, html=False, sku=None, runtime=None): 355 if sku is None: 356 if runtime: # user overrided language detection by specifiying runtime 357 return 'F1' 358 lang_details = get_lang_from_content(src_dir, html) 359 return lang_details.get("default_sku") 360 logger.info("Found sku argument, skipping use default sku") 361 return sku 362 363 364def set_language(src_dir, html=False): 365 lang_details = get_lang_from_content(src_dir, html) 366 return lang_details.get('language') 367 368 369def detect_os_form_src(src_dir, html=False): 370 lang_details = get_lang_from_content(src_dir, html) 371 language = lang_details.get('language') 372 return "Linux" if language is not None and language.lower() == NODE_RUNTIME_NAME \ 373 or language.lower() == PYTHON_RUNTIME_NAME else OS_DEFAULT 374 375 376def get_plan_to_use(cmd, user, os_name, loc, sku, create_rg, resource_group_name, plan=None): 377 _default_asp = "{}_asp_{}_{}_0".format(user, os_name, loc) 378 if plan is None: # --plan not provided by user 379 # get the plan name to use 380 return _determine_if_default_plan_to_use(cmd, _default_asp, resource_group_name, loc, sku, create_rg) 381 return plan 382 383 384# Portal uses the current_stack property in the app metadata to display the correct stack 385# This value should be one of: ['dotnet', 'dotnetcore', 'node', 'php', 'python', 'java'] 386def get_current_stack_from_runtime(runtime): 387 language = runtime.split('|')[0].lower() 388 if language == 'aspnet': 389 return 'dotnet' 390 return language 391 392 393# if plan name not provided we need to get a plan name based on the OS, location & SKU 394def _determine_if_default_plan_to_use(cmd, plan_name, resource_group_name, loc, sku, create_rg): 395 client = web_client_factory(cmd.cli_ctx) 396 if create_rg: # if new RG needs to be created use the default name 397 return plan_name 398 # get all ASPs in the RG & filter to the ones that contain the plan_name 399 _asp_generic = plan_name[:-len(plan_name.split("_")[4])] 400 _asp_list = (list(filter(lambda x: _asp_generic in x.name, 401 client.app_service_plans.list_by_resource_group(resource_group_name)))) 402 _num_asp = len(_asp_list) 403 if _num_asp: 404 # check if we have at least one app that can be used with the combination of loc, sku & os 405 selected_asp = next((a for a in _asp_list if isinstance(a.sku, SkuDescription) and 406 a.sku.name.lower() == sku.lower() and 407 (a.location.replace(" ", "").lower() == loc.lower())), None) 408 if selected_asp is not None: 409 return selected_asp.name 410 # from the sorted data pick the last one & check if a new ASP needs to be created 411 # based on SKU or not 412 data_sorted = sorted(_asp_list, key=lambda x: x.name) 413 _plan_info = data_sorted[_num_asp - 1] 414 _asp_num = 1 415 try: 416 _asp_num = int(_plan_info.name.split('_')[-1]) + 1 # default asp created by CLI can be of type plan_num 417 except ValueError: 418 pass 419 return '{}_{}'.format(_asp_generic, _asp_num) 420 return plan_name 421 422 423def should_create_new_app(cmd, rg_name, app_name): # this is currently referenced by an extension command 424 client = web_client_factory(cmd.cli_ctx) 425 for item in list(client.web_apps.list_by_resource_group(rg_name)): 426 if item.name.lower() == app_name.lower(): 427 return False 428 return True 429 430 431def generate_default_app_name(cmd): 432 def generate_name(cmd): 433 import uuid 434 from random import choice 435 436 noun = choice(get_file_json(GENERATE_RANDOM_APP_NAMES)['APP_NAME_NOUNS']) 437 adjective = choice(get_file_json(GENERATE_RANDOM_APP_NAMES)['APP_NAME_ADJECTIVES']) 438 random_uuid = str(uuid.uuid4().hex) 439 440 name = '{}-{}-{}'.format(adjective, noun, random_uuid) 441 name_available = get_site_availability(cmd, name).name_available 442 443 if name_available: 444 return name 445 return "" 446 447 retry_times = 5 448 generated_name = generate_name(cmd) 449 while not generated_name and retry_times > 0: 450 retry_times -= 1 451 generated_name = generate_name(cmd) 452 453 if not generated_name: 454 raise CLIError("Unable to generate a default name for webapp. Please specify webapp name using --name flag") 455 return generated_name 456