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
6from __future__ import print_function
7import re
8import time
9import uuid
10
11try:
12    from urllib.parse import quote, urlparse
13except ImportError:
14    from urllib import quote  #pylint: disable=no-name-in-module
15    from urlparse import urlparse  #pylint: disable=import-error
16from vsts_info_provider import VstsInfoProvider
17from continuous_delivery import ContinuousDelivery
18from continuous_delivery.models import (AuthorizationInfo, AuthorizationInfoParameters, BuildConfiguration,
19                                        CiArtifact, CiConfiguration, ProvisioningConfiguration,
20                                        ProvisioningConfigurationSource, ProvisioningConfigurationTarget,
21                                        SlotSwapConfiguration, SourceRepository, CreateOptions)
22from aex_accounts import Account
23
24# Use this class to setup or remove continuous delivery mechanisms for Azure web sites using VSTS build and release
25class ContinuousDeliveryManager(object):
26    def __init__(self, progress_callback):
27        """
28        Use this class to setup or remove continuous delivery mechanisms for Azure web sites using VSTS build and release
29        :param progress_callback: method of the form func(count, total, message)
30        """
31        self._update_progress = progress_callback or self._skip_update_progress
32        self._azure_info = _AzureInfo()
33        self._repo_info = _RepositoryInfo()
34
35    def get_vsts_app_id(self):
36        """
37        Use this method to get the 'resource' value for creating an Azure token to be used by VSTS
38        :return: App id for VSTS
39        """
40        return '499b84ac-1321-427f-aa17-267ca6975798'
41
42    def set_azure_web_info(self, resource_group_name, website_name, credentials,
43                           subscription_id, subscription_name, tenant_id, webapp_location):
44        """
45        Call this method before attempting to setup continuous delivery to setup the azure settings
46        :param resource_group_name:
47        :param website_name:
48        :param credentials:
49        :param subscription_id:
50        :param subscription_name:
51        :param tenant_id:
52        :param webapp_location:
53        :return:
54        """
55        self._azure_info.resource_group_name = resource_group_name
56        self._azure_info.website_name = website_name
57        self._azure_info.credentials = credentials
58        self._azure_info.subscription_id = subscription_id
59        self._azure_info.subscription_name = subscription_name
60        self._azure_info.tenant_id = tenant_id
61        self._azure_info.webapp_location = webapp_location
62
63    def set_repository_info(self, repo_url, branch, git_token, private_repo_username, private_repo_password):
64        """
65        Call this method before attempting to setup continuous delivery to setup the source control settings
66        :param repo_url: URL of the code repo
67        :param branch: repo branch
68        :param git_token: git token
69        :param private_repo_username: private repo username
70        :param private_repo_password: private repo password
71        :return:
72        """
73        self._repo_info.url = repo_url
74        self._repo_info.branch = branch
75        self._repo_info.git_token = git_token
76        self._repo_info._private_repo_username = private_repo_username
77        self._repo_info._private_repo_password = private_repo_password
78
79    def remove_continuous_delivery(self):
80        """
81        To be Implemented
82        :return:
83        """
84        # TODO: this would be called by appservice web source-control delete
85        return
86
87    def setup_continuous_delivery(self, swap_with_slot, app_type_details, cd_project_url, create_account,
88                                  vsts_app_auth_token, test, webapp_list):
89        """
90        Use this method to setup Continuous Delivery of an Azure web site from a source control repository.
91        :param swap_with_slot: the slot to use for deployment
92        :param app_type_details: the details of app that will be deployed. i.e. app_type = Python, python_framework = Django etc.
93        :param cd_project_url: CD Project url in the format of https://<accountname>.visualstudio.com/<projectname>
94        :param create_account: Boolean value to decide if account need to be created or not
95        :param vsts_app_auth_token: Authentication token for vsts app
96        :param test: Load test webapp name
97        :param webapp_list: Existing webapp list
98        :return: a message indicating final status and instructions for the user
99        """
100
101        branch = self._repo_info.branch or 'refs/heads/master'
102        self._validate_cd_project_url(cd_project_url)
103        vsts_account_name = self._get_vsts_account_name(cd_project_url)
104
105        # Verify inputs before we start generating tokens
106        source_repository, account_name, team_project_name = self._get_source_repository(self._repo_info.url,
107            self._repo_info.git_token, branch, self._azure_info.credentials,
108            self._repo_info._private_repo_username, self._repo_info._private_repo_password)
109        self._verify_vsts_parameters(vsts_account_name, source_repository)
110        vsts_account_name = vsts_account_name or account_name
111        cd_project_name = team_project_name or self._azure_info.website_name
112        account_url = 'https://{}.visualstudio.com'.format(quote(vsts_account_name))
113        portalext_account_url = 'https://{}.portalext.visualstudio.com'.format(quote(vsts_account_name))
114
115        # VSTS Account using AEX APIs
116        account_created = False
117        if create_account:
118            self.create_vsts_account(self._azure_info.credentials, vsts_account_name)
119            account_created = True
120
121        # Create ContinuousDelivery client
122        cd = ContinuousDelivery('3.2-preview.1', portalext_account_url, self._azure_info.credentials)
123
124        # Construct the config body of the continuous delivery call
125        build_configuration = self._get_build_configuration(app_type_details)
126        source = ProvisioningConfigurationSource('codeRepository', source_repository, build_configuration)
127        auth_info = AuthorizationInfo('Headers', AuthorizationInfoParameters('Bearer ' + vsts_app_auth_token))
128        target = self.get_provisioning_configuration_target(auth_info, swap_with_slot, test, webapp_list)
129        ci_config = CiConfiguration(CiArtifact(name=cd_project_name))
130        config = ProvisioningConfiguration(None, source, target, ci_config)
131
132        # Configure the continuous deliver using VSTS as a backend
133        response = cd.provisioning_configuration(config)
134        if response.ci_configuration.result.status == 'queued':
135            final_status = self._wait_for_cd_completion(cd, response)
136            return self._get_summary(final_status, account_url, vsts_account_name, account_created, self._azure_info.subscription_id,
137                                     self._azure_info.resource_group_name, self._azure_info.website_name)
138        else:
139            raise RuntimeError('Unknown status returned from provisioning_configuration: ' + response.ci_configuration.result.status)
140
141    def create_vsts_account(self, creds, vsts_account_name):
142        aex_url = 'https://app.vsaex.visualstudio.com'
143        accountClient = Account('4.0-preview.1', aex_url, creds)
144        self._update_progress(0, 100, 'Creating or getting Team Services account information')
145        regions = accountClient.regions()
146        if regions.count == 0:
147            raise RuntimeError('Region details not found.')
148        region_name = regions.value[0].name
149        create_account_reponse = accountClient.create_account(vsts_account_name, region_name)
150        if create_account_reponse.id:
151            self._update_progress(5, 100, 'Team Services account created')
152        else:
153            raise RuntimeError('Account creation failed.')
154
155    def _validate_cd_project_url(self, cd_project_url):
156        if -1 == cd_project_url.find('visualstudio.com') or -1 == cd_project_url.find('https://'):
157            raise RuntimeError('Project URL should be in format https://<accountname>.visualstudio.com/<projectname>')
158
159    def _get_vsts_account_name(self, cd_project_url):
160        return (cd_project_url.split('.visualstudio.com', 1)[0]).split('https://', 1)[1]
161
162    def get_provisioning_configuration_target(self, auth_info, swap_with_slot, test, webapp_list):
163        swap_with_slot_config = None if swap_with_slot is None else SlotSwapConfiguration(swap_with_slot)
164        slotTarget = ProvisioningConfigurationTarget('azure', 'windowsAppService', 'production', 'Production',
165                                                 self._azure_info.subscription_id, self._azure_info.subscription_name,
166                                                 self._azure_info.tenant_id, self._azure_info.website_name,
167                                                 self._azure_info.resource_group_name, self._azure_info.webapp_location,
168                                                 auth_info, swap_with_slot_config)
169        target = [slotTarget]
170        if test is not None:
171            create_options = None
172            if webapp_list is not None and not any(s.name == test for s in webapp_list) :
173                app_service_plan_name = 'ServicePlan'+ str(uuid.uuid4())[:13]
174                create_options = CreateOptions(app_service_plan_name, 'Standard', self._azure_info.website_name)
175            testTarget = ProvisioningConfigurationTarget('azure', 'windowsAppService', 'test', 'Load Test',
176                                                    self._azure_info.subscription_id,
177                                                    self._azure_info.subscription_name, self._azure_info.tenant_id,
178                                                    test, self._azure_info.resource_group_name,
179                                                    self._azure_info.webapp_location, auth_info, None, create_options)
180            target.append(testTarget)
181        return target
182
183    def _verify_vsts_parameters(self, cd_account, source_repository):
184        # if provider is vsts and repo is not vsts then we need the account name
185        if source_repository.type in ['Github', 'ExternalGit'] and not cd_account:
186            raise RuntimeError('You must provide a value for cd-account since your repo-url is not a Team Services repository.')
187
188    def _get_build_configuration(self, app_type_details):
189        accepted_app_types = ['AspNet', 'AspNetCore', 'NodeJS', 'PHP', 'Python']
190        accepted_nodejs_task_runners = ['None', 'Gulp', 'Grunt']
191        accepted_python_frameworks = ['Bottle', 'Django', 'Flask']
192        accepted_python_versions = ['Python 2.7.12 x64', 'Python 2.7.12 x86', 'Python 2.7.13 x64', 'Python 2.7.13 x86', 'Python 3.5.3 x64', 'Python 3.5.3 x86', 'Python 3.6.0 x64', 'Python 3.6.0 x86', 'Python 3.6.2 x64', 'Python 3.6.1 x86']
193
194        build_configuration = None
195        working_directory = app_type_details.get('app_working_dir')
196        app_type = app_type_details.get('cd_app_type')
197        if (app_type == 'AspNet') :
198            build_configuration = BuildConfiguration('AspNetWap', working_directory)
199        elif (app_type == 'AspNetCore') or (app_type == 'PHP') :
200            build_configuration = BuildConfiguration(app_type, working_directory)
201        elif app_type == 'NodeJS' :
202            nodejs_task_runner = app_type_details.get('nodejs_task_runner')
203            if any(s == nodejs_task_runner for s in accepted_nodejs_task_runners) :
204                build_configuration = BuildConfiguration(app_type, working_directory, nodejs_task_runner)
205            else:
206                raise RuntimeError("The nodejs_task_runner %s was not understood. Accepted values: %s." % (nodejs_task_runner, accepted_nodejs_task_runners))
207        elif app_type == 'Python' :
208            python_framework = app_type_details.get('python_framework')
209            python_version = app_type_details.get('python_version')
210            django_setting_module = 'DjangoProjectName.settings'
211            flask_project_name = 'FlaskProjectName'
212            if any(s == python_framework for s in accepted_python_frameworks) :
213                if any(s == python_version for s in accepted_python_versions) :
214                    python_version = python_version.replace(" ", "").replace(".", "")
215                    build_configuration = BuildConfiguration(app_type, working_directory, None, python_framework, python_version, django_setting_module, flask_project_name)
216                else :
217                    raise RuntimeError("The python_version %s was not understood. Accepted values: %s." % (python_version, accepted_python_versions))
218            else:
219                raise RuntimeError("The python_framework %s was not understood. Accepted values: %s." % (python_framework, accepted_python_frameworks))
220        else:
221            raise RuntimeError("The app_type %s was not understood. Accepted values: %s." % (app_type, accepted_app_types))
222        return build_configuration
223
224    def _get_source_repository(self, uri, token, branch, cred, username, password):
225        # Determine the type of repository (TfsGit, github, tfvc, externalGit)
226        # Find the identifier and set the properties.
227        # Default is externalGit
228        type = 'Git'
229        identifier = uri
230        account_name = None
231        team_project_name = None
232        auth_info = AuthorizationInfo('UsernamePassword', AuthorizationInfoParameters(None, None, username, password))
233
234        match = re.match(r'[htps]+\:\/\/(.+)\.visualstudio\.com.*\/_git\/(.+)', uri, re.IGNORECASE)
235        if match:
236            type = 'TfsGit'
237            account_name = match.group(1)
238            # we have to get the repo id as the identifier
239            info = self._get_vsts_info(uri, cred)
240            identifier = info.repository_info.id
241            team_project_name = info.repository_info.project_info.name
242            auth_info = None
243        else:
244            match = re.match(r'[htps]+\:\/\/github\.com\/(.+)', uri, re.IGNORECASE)
245            if match:
246                if token is not None:
247                    type = 'Github'
248                    identifier = match.group(1).replace(".git", "")
249                    auth_info = AuthorizationInfo('PersonalAccessToken', AuthorizationInfoParameters(None, token))
250            else:
251                match = re.match(r'[htps]+\:\/\/(.+)\.visualstudio\.com\/(.+)', uri, re.IGNORECASE)
252                if match:
253                    type = 'TFVC'
254                    identifier = match.group(2)
255                    account_name = match.group(1)
256                    auth_info = None
257        sourceRepository = SourceRepository(type, identifier, branch, auth_info)
258        return sourceRepository, account_name, team_project_name
259
260    def _get_vsts_info(self, vsts_repo_url, cred):
261        vsts_info_client = VstsInfoProvider('3.2-preview', vsts_repo_url, cred)
262        return vsts_info_client.get_vsts_info()
263
264    def _wait_for_cd_completion(self, cd, response):
265        # Wait for the configuration to finish and report on the status
266        step = 5
267        max = 100
268        self._update_progress(step, max, 'Setting up Team Services continuous deployment')
269        config = cd.get_provisioning_configuration(response.id)
270        while config.ci_configuration.result.status == 'queued' or config.ci_configuration.result.status == 'inProgress':
271            step += 5 if step + 5 < max else 0
272            self._update_progress(step, max, 'Setting up Team Services continuous deployment (' + config.ci_configuration.result.status + ')')
273            time.sleep(2)
274            config = cd.get_provisioning_configuration(response.id)
275        if config.ci_configuration.result.status == 'failed':
276            self._update_progress(max, max, 'Setting up Team Services continuous deployment (FAILED)')
277            raise RuntimeError(config.ci_configuration.result.status_message)
278        self._update_progress(max, max, 'Setting up Team Services continuous deployment (SUCCEEDED)')
279        return config
280
281    def _get_summary(self, provisioning_configuration, account_url, account_name, account_created, subscription_id, resource_group_name, website_name):
282        summary = '\n'
283        if not provisioning_configuration: return None
284
285        # Add the vsts account info
286        if not account_created:
287            summary += "The Team Services account '{}' was updated to handle the continuous delivery.\n".format(account_url)
288        else:
289            summary += "The Team Services account '{}' was created to handle the continuous delivery.\n".format(account_url)
290
291        # Add the subscription info
292        website_url = 'https://portal.azure.com/#resource/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Web/sites/{}/vstscd'.format(
293            quote(subscription_id), quote(resource_group_name), quote(website_name))
294        summary += 'You can check on the status of the Azure web site deployment here:\n'
295        summary += website_url + '\n'
296
297        # setup the build url and release url
298        build_url = ''
299        release_url = ''
300        if provisioning_configuration.ci_configuration and provisioning_configuration.ci_configuration.project:
301            project_id = provisioning_configuration.ci_configuration.project.id
302            if provisioning_configuration.ci_configuration.build_definition:
303                build_url = '{}/{}/_build?_a=simple-process&definitionId={}'.format(
304                    account_url, quote(project_id), quote(provisioning_configuration.ci_configuration.build_definition.id))
305            if provisioning_configuration.ci_configuration.release_definition:
306                release_url = '{}/{}/_apps/hub/ms.vss-releaseManagement-web.hub-explorer?definitionId={}&_a=releases'.format(
307                    account_url, quote(project_id), quote(provisioning_configuration.ci_configuration.release_definition.id))
308
309        return ContinuousDeliveryResult(account_created, account_url, resource_group_name,
310                                        subscription_id, website_name, website_url, summary,
311                                        build_url, release_url, provisioning_configuration)
312
313    def _skip_update_progress(self, count, total, message):
314        return
315
316
317class _AzureInfo(object):
318    def __init__(self):
319        self.resource_group_name = None
320        self.website_name = None
321        self.credentials = None
322        self.subscription_id = None
323        self.subscription_name = None
324        self.tenant_id = None
325        self.webapp_location = None
326
327
328class _RepositoryInfo(object):
329    def __init__(self):
330        self.url = None
331        self.branch = None
332        self.git_token = None
333        self.private_repo_username = None
334        self.private_repo_password = None
335
336
337class ContinuousDeliveryResult(object):
338    def __init__(self, account_created, account_url, resource_group, subscription_id, website_name, cd_url, message, build_url, release_url, final_status):
339        self.vsts_account_created = account_created
340        self.vsts_account_url = account_url
341        self.vsts_build_def_url = build_url
342        self.vsts_release_def_url = release_url
343        self.azure_resource_group = resource_group
344        self.azure_subscription_id = subscription_id
345        self.azure_website_name = website_name
346        self.azure_continuous_delivery_url = cd_url
347        self.status = 'SUCCESS'
348        self.status_message = message
349        self.status_details = final_status
350