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