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