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 time
8import json
9import logging
10from knack.prompting import prompt_choice_list, prompt_y_n, prompt
11from knack.util import CLIError
12from azure_functions_devops_build.constants import (
13    LINUX_CONSUMPTION,
14    LINUX_DEDICATED,
15    WINDOWS,
16    PYTHON,
17    NODE,
18    DOTNET,
19    POWERSHELL
20)
21from azure_functions_devops_build.exceptions import (
22    GitOperationException,
23    RoleAssignmentException,
24    LanguageNotSupportException,
25    BuildErrorException,
26    ReleaseErrorException,
27    GithubContentNotFound,
28    GithubUnauthorizedError,
29    GithubIntegrationRequestError
30)
31from .azure_devops_build_provider import AzureDevopsBuildProvider
32from .custom import list_function_app, show_webapp, get_app_settings
33from .utils import str2bool
34
35# pylint: disable=too-many-instance-attributes,too-many-public-methods
36
37SUPPORTED_SCENARIOS = ['AZURE_DEVOPS', 'GITHUB_INTEGRATION']
38SUPPORTED_SOURCECODE_LOCATIONS = ['Current Directory', 'Github']
39SUPPORTED_LANGUAGES = {
40    'python': PYTHON,
41    'node': NODE,
42    'dotnet': DOTNET,
43    'powershell': POWERSHELL,
44}
45BUILD_QUERY_FREQUENCY = 5  # sec
46RELEASE_COMPOSITION_DELAY = 1  # sec
47
48
49class AzureDevopsBuildInteractive(object):
50    """Implement the basic user flow for a new user wanting to do an Azure DevOps build for Azure Functions
51
52    Attributes:
53        cmd : the cmd input from the command line
54        logger : a knack logger to log the info/error messages
55    """
56
57    def __init__(self, cmd, logger, functionapp_name, organization_name, project_name, repository_name,
58                 overwrite_yaml, allow_force_push, github_pat, github_repository):
59        self.adbp = AzureDevopsBuildProvider(cmd.cli_ctx)
60        self.cmd = cmd
61        self.logger = logger
62        self.cmd_selector = CmdSelectors(cmd, logger, self.adbp)
63        self.functionapp_name = functionapp_name
64        self.storage_name = None
65        self.resource_group_name = None
66        self.functionapp_language = None
67        self.functionapp_type = None
68        self.organization_name = organization_name
69        self.project_name = project_name
70        self.repository_name = repository_name
71
72        self.github_pat = github_pat
73        self.github_repository = github_repository
74        self.github_service_endpoint_name = None
75
76        self.repository_remote_name = None
77        self.service_endpoint_name = None
78        self.build_definition_name = None
79        self.release_definition_name = None
80        self.build_pool_name = "Default"
81        self.release_pool_name = "Hosted VS2017"
82        self.artifact_name = "drop"
83
84        self.settings = []
85        self.build = None
86        # These are used to tell if we made new objects
87        self.scenario = None  # see SUPPORTED_SCENARIOS
88        self.created_organization = False
89        self.created_project = False
90        self.overwrite_yaml = str2bool(overwrite_yaml)
91        self.allow_force_push = allow_force_push
92
93    def interactive_azure_devops_build(self):
94        """Main interactive flow which is the only function that should be used outside of this
95        class (the rest are helpers)"""
96
97        scenario = self.check_scenario()
98        if scenario == 'AZURE_DEVOPS':
99            return self.azure_devops_flow()
100        if scenario == 'GITHUB_INTEGRATION':
101            return self.github_flow()
102        raise CLIError('Unknown scenario')
103
104    def azure_devops_flow(self):
105        self.process_functionapp()
106        self.pre_checks_azure_devops()
107        self.process_organization()
108        self.process_project()
109        self.process_yaml_local()
110        self.process_local_repository()
111        self.process_remote_repository()
112        self.process_functionapp_service_endpoint('AZURE_DEVOPS')
113        self.process_extensions()
114        self.process_build_and_release_definition_name('AZURE_DEVOPS')
115        self.process_build('AZURE_DEVOPS')
116        self.wait_for_build()
117        self.process_release()
118        self.logger.warning("Pushing your code to {remote}:master will now trigger another build.".format(
119            remote=self.repository_remote_name
120        ))
121        return {
122            'source_location': 'local',
123            'functionapp_name': self.functionapp_name,
124            'storage_name': self.storage_name,
125            'resource_group_name': self.resource_group_name,
126            'functionapp_language': self.functionapp_language,
127            'functionapp_type': self.functionapp_type,
128            'organization_name': self.organization_name,
129            'project_name': self.project_name,
130            'repository_name': self.repository_name,
131            'service_endpoint_name': self.service_endpoint_name,
132            'build_definition_name': self.build_definition_name,
133            'release_definition_name': self.release_definition_name
134        }
135
136    def github_flow(self):
137        self.process_github_personal_access_token()
138        self.process_github_repository()
139        self.process_functionapp()
140        self.pre_checks_github()
141        self.process_organization()
142        self.process_project()
143        self.process_yaml_github()
144        self.process_functionapp_service_endpoint('GITHUB_INTEGRATION')
145        self.process_github_service_endpoint()
146        self.process_extensions()
147        self.process_build_and_release_definition_name('GITHUB_INTEGRATION')
148        self.process_build('GITHUB_INTEGRATION')
149        self.wait_for_build()
150        self.process_release()
151        self.logger.warning("Setup continuous integration between {github_repo} and Azure Pipelines".format(
152            github_repo=self.github_repository
153        ))
154        return {
155            'source_location': 'Github',
156            'functionapp_name': self.functionapp_name,
157            'storage_name': self.storage_name,
158            'resource_group_name': self.resource_group_name,
159            'functionapp_language': self.functionapp_language,
160            'functionapp_type': self.functionapp_type,
161            'organization_name': self.organization_name,
162            'project_name': self.project_name,
163            'repository_name': self.github_repository,
164            'service_endpoint_name': self.service_endpoint_name,
165            'build_definition_name': self.build_definition_name,
166            'release_definition_name': self.release_definition_name
167        }
168
169    def check_scenario(self):
170        if self.repository_name:
171            self.scenario = 'AZURE_DEVOPS'
172        elif self.github_pat or self.github_repository:
173            self.scenario = 'GITHUB_INTEGRATION'
174        else:
175            choice_index = prompt_choice_list(
176                'Please choose Azure function source code location: ',
177                SUPPORTED_SOURCECODE_LOCATIONS
178            )
179            self.scenario = SUPPORTED_SCENARIOS[choice_index]
180        return self.scenario
181
182    def pre_checks_azure_devops(self):
183        if not AzureDevopsBuildProvider.check_git():
184            raise CLIError("The program requires git source control to operate, please install git.")
185
186        if not os.path.exists('host.json'):
187            raise CLIError("There is no host.json in the current directory.{ls}"
188                           "Functionapps must contain a host.json in their root".format(ls=os.linesep))
189
190        if not os.path.exists('local.settings.json'):
191            raise CLIError("There is no local.settings.json in the current directory.{ls}"
192                           "Functionapps must contain a local.settings.json in their root".format(ls=os.linesep))
193
194        try:
195            local_runtime_language = self._find_local_repository_runtime_language()
196        except LanguageNotSupportException as lnse:
197            raise CLIError("Sorry, currently we do not support {language}.".format(language=lnse.message))
198
199        if local_runtime_language != self.functionapp_language:
200            raise CLIError("The language stack setting found in your local repository ({setting}) does not match "
201                           "the language stack of your Azure function app in Azure ({functionapp}).{ls}"
202                           "Please look at the FUNCTIONS_WORKER_RUNTIME setting both in your local.settings.json file "
203                           "and in your Azure function app's application settings, "
204                           "and ensure they match.".format(
205                               setting=local_runtime_language,
206                               functionapp=self.functionapp_language,
207                               ls=os.linesep,
208                           ))
209
210    def pre_checks_github(self):
211        if not AzureDevopsBuildProvider.check_github_file(self.github_pat, self.github_repository, 'host.json'):
212            raise CLIError("There is no host.json file in the provided Github repository {repo}.{ls}"
213                           "Each function app must contain a host.json in their root.{ls}"
214                           "Please ensure you have read permission to the repository.".format(
215                               repo=self.github_repository,
216                               ls=os.linesep,
217                           ))
218        try:
219            github_runtime_language = self._find_github_repository_runtime_language()
220        except LanguageNotSupportException as lnse:
221            raise CLIError("Sorry, currently we do not support {language}.".format(language=lnse.message))
222
223        if github_runtime_language is not None and github_runtime_language != self.functionapp_language:
224            raise CLIError("The language stack setting found in the provided repository ({setting}) does not match "
225                           "the language stack of your Azure function app ({functionapp}).{ls}"
226                           "Please look at the FUNCTIONS_WORKER_RUNTIME setting both in your local.settings.json file "
227                           "and in your Azure function app's application settings, "
228                           "and ensure they match.".format(
229                               setting=github_runtime_language,
230                               functionapp=self.functionapp_language,
231                               ls=os.linesep,
232                           ))
233
234    def process_functionapp(self):
235        """Helper to retrieve information about a functionapp"""
236        if self.functionapp_name is None:
237            functionapp = self._select_functionapp()
238            self.functionapp_name = functionapp.name
239        else:
240            functionapp = self.cmd_selector.cmd_functionapp(self.functionapp_name)
241
242        kinds = show_webapp(self.cmd, functionapp.resource_group, functionapp.name).kind.split(',')
243
244        # Get functionapp settings in Azure
245        app_settings = get_app_settings(self.cmd, functionapp.resource_group, functionapp.name)
246
247        self.resource_group_name = functionapp.resource_group
248        self.functionapp_type = self._find_type(kinds)
249
250        try:
251            self.functionapp_language = self._get_functionapp_runtime_language(app_settings)
252            self.storage_name = self._get_functionapp_storage_name(app_settings)
253        except LanguageNotSupportException as lnse:
254            raise CLIError("Sorry, currently we do not support {language}.".format(language=lnse.message))
255
256    def process_organization(self):
257        """Helper to retrieve information about an organization / create a new one"""
258        if self.organization_name is None:
259            response = prompt_y_n('Would you like to use an existing Azure DevOps organization? ')
260            if response:
261                self._select_organization()
262            else:
263                self._create_organization()
264                self.created_organization = True
265        else:
266            self.cmd_selector.cmd_organization(self.organization_name)
267
268    def process_project(self):
269        """Helper to retrieve information about a project / create a new one"""
270        # There is a new organization so a new project will be needed
271        if (self.project_name is None) and (self.created_organization):
272            self._create_project()
273        elif self.project_name is None:
274            use_existing_project = prompt_y_n('Would you like to use an existing Azure DevOps project? ')
275            if use_existing_project:
276                self._select_project()
277            else:
278                self._create_project()
279        else:
280            self.cmd_selector.cmd_project(self.organization_name, self.project_name)
281
282        self.logger.warning("To view your Azure DevOps project, "
283                            "please visit https://dev.azure.com/{org}/{proj}".format(
284                                org=self.organization_name,
285                                proj=self.project_name
286                            ))
287
288    def process_yaml_local(self):
289        """Helper to create the local azure-pipelines.yml file"""
290
291        if os.path.exists('azure-pipelines.yml'):
292            if self.overwrite_yaml is None:
293                self.logger.warning("There is already an azure-pipelines.yml file in your local repository.")
294                self.logger.warning("If you are using a yaml file that was not configured "
295                                    "through this command, this command may fail.")
296                response = prompt_y_n("Do you want to delete it and create a new one? ")
297            else:
298                response = self.overwrite_yaml
299
300        if (not os.path.exists('azure-pipelines.yml')) or response:
301            self.logger.warning('Creating a new azure-pipelines.yml')
302            try:
303                self.adbp.create_yaml(self.functionapp_language, self.functionapp_type)
304            except LanguageNotSupportException as lnse:
305                raise CLIError("Sorry, currently we do not support {language}.".format(language=lnse.message))
306
307    def process_yaml_github(self):
308        does_yaml_file_exist = AzureDevopsBuildProvider.check_github_file(
309            self.github_pat,
310            self.github_repository,
311            "azure-pipelines.yml"
312        )
313        if does_yaml_file_exist and self.overwrite_yaml is None:
314            self.logger.warning("There is already an azure-pipelines.yml file in the provided Github repository.")
315            self.logger.warning("If you are using a yaml file that was not configured "
316                                "through this command, this command may fail.")
317            self.overwrite_yaml = prompt_y_n("Do you want to generate a new one? "
318                                             "(It will be committed to the master branch of the provided repository)")
319
320        # Create and commit the new yaml file to Github without asking
321        if not does_yaml_file_exist:
322            if self.github_repository:
323                self.logger.warning("Creating a new azure-pipelines.yml for Github repository")
324            try:
325                AzureDevopsBuildProvider.create_github_yaml(
326                    pat=self.github_pat,
327                    language=self.functionapp_language,
328                    app_type=self.functionapp_type,
329                    repository_fullname=self.github_repository
330                )
331            except LanguageNotSupportException as lnse:
332                raise CLIError("Sorry, currently this command does not support {language}. To proceed, "
333                               "you'll need to configure your build manually at dev.azure.com".format(
334                                   language=lnse.message))
335            except GithubContentNotFound:
336                raise CLIError("Sorry, the repository you provided does not exist or "
337                               "you do not have sufficient permissions to write to the repository. "
338                               "Please provide an access token with the proper permissions.")
339            except GithubUnauthorizedError:
340                raise CLIError("Sorry, you do not have sufficient permissions to commit "
341                               "azure-pipelines.yml to your Github repository.")
342
343        # Overwrite yaml file
344        if does_yaml_file_exist and self.overwrite_yaml:
345            self.logger.warning("Overwrite azure-pipelines.yml file in the provided Github repository")
346            try:
347                AzureDevopsBuildProvider.create_github_yaml(
348                    pat=self.github_pat,
349                    language=self.functionapp_language,
350                    app_type=self.functionapp_type,
351                    repository_fullname=self.github_repository,
352                    overwrite=True
353                )
354            except LanguageNotSupportException as lnse:
355                raise CLIError("Sorry, currently this command does not support {language}. To proceed, "
356                               "you'll need to configure your build manually at dev.azure.com".format(
357                                   language=lnse.message))
358            except GithubContentNotFound:
359                raise CLIError("Sorry, the repository you provided does not exist or "
360                               "you do not have sufficient permissions to write to the repository. "
361                               "Please provide an access token with the proper permissions.")
362            except GithubUnauthorizedError:
363                raise CLIError("Sorry, you do not have sufficient permissions to overwrite "
364                               "azure-pipelines.yml in your Github repository.")
365
366    def process_local_repository(self):
367        has_local_git_repository = AzureDevopsBuildProvider.check_git_local_repository()
368        if has_local_git_repository:
369            self.logger.warning("Detected a local Git repository already exists.")
370
371        # Collect repository name on Azure Devops
372        if not self.repository_name:
373            self.repository_name = prompt("Push to which Azure DevOps repository (default: {repo}): ".format(
374                repo=self.project_name
375            ))
376            if not self.repository_name:  # Select default value
377                self.repository_name = self.project_name
378
379        expected_remote_name = self.adbp.get_local_git_remote_name(
380            self.organization_name,
381            self.project_name,
382            self.repository_name
383        )
384        expected_remote_url = self.adbp.get_azure_devops_repo_url(
385            self.organization_name,
386            self.project_name,
387            self.repository_name
388        )
389
390        # If local repository already has a remote
391        # Let the user to know s/he can push to the remote directly for context update
392        # Or let s/he remove the git remote manually
393        has_local_git_remote = self.adbp.check_git_remote(
394            self.organization_name,
395            self.project_name,
396            self.repository_name
397        )
398        if has_local_git_remote:
399            raise CLIError("There's a git remote bound to {url}.{ls}"
400                           "To update the repository and trigger an Azure Pipelines build, please use "
401                           "'git push {remote} master'".format(
402                               url=expected_remote_url,
403                               remote=expected_remote_name,
404                               ls=os.linesep)
405                           )
406
407        # Setup a local git repository and create a new commit on top of this context
408        try:
409            self.adbp.setup_local_git_repository(self.organization_name, self.project_name, self.repository_name)
410        except GitOperationException as goe:
411            raise CLIError("Failed to setup local git repository when running '{message}'{ls}"
412                           "Please ensure you have setup git user.email and user.name".format(
413                               message=goe.message, ls=os.linesep
414                           ))
415
416        self.repository_remote_name = expected_remote_name
417        self.logger.warning("Added git remote {remote}".format(remote=expected_remote_name))
418
419    def process_remote_repository(self):
420        # Create remote repository if it does not exist
421        repository = self.adbp.get_azure_devops_repository(
422            self.organization_name,
423            self.project_name,
424            self.repository_name
425        )
426        if not repository:
427            self.adbp.create_repository(self.organization_name, self.project_name, self.repository_name)
428
429        # Force push branches if repository is not clean
430        remote_url = self.adbp.get_azure_devops_repo_url(
431            self.organization_name,
432            self.project_name,
433            self.repository_name
434        )
435        remote_branches = self.adbp.get_azure_devops_repository_branches(
436            self.organization_name,
437            self.project_name,
438            self.repository_name
439        )
440        is_force_push = self._check_if_force_push_required(remote_url, remote_branches)
441
442        # Prompt user to generate a git credential
443        self._check_if_git_credential_required()
444
445        # If the repository does not exist, we will do a normal push
446        # If the repository exists, we will do a force push
447        try:
448            self.adbp.push_local_to_azure_devops_repository(
449                self.organization_name,
450                self.project_name,
451                self.repository_name,
452                force=is_force_push
453            )
454        except GitOperationException:
455            self.adbp.remove_git_remote(self.organization_name, self.project_name, self.repository_name)
456            raise CLIError("Failed to push your local repository to {url}{ls}"
457                           "Please check your credentials and ensure you are a contributor to the repository.".format(
458                               url=remote_url, ls=os.linesep
459                           ))
460
461        self.logger.warning("Local branches have been pushed to {url}".format(url=remote_url))
462
463    def process_github_personal_access_token(self):
464        if not self.github_pat:
465            self.logger.warning("If you need to create a Github Personal Access Token, "
466                                "please follow the steps found at the following link:")
467            self.logger.warning("https://help.github.com/en/articles/"
468                                "creating-a-personal-access-token-for-the-command-line{ls}".format(ls=os.linesep))
469            self.logger.warning("The required Personal Access Token permissions can be found here:")
470            self.logger.warning("https://aka.ms/azure-devops-source-repos")
471
472        while not self.github_pat or not AzureDevopsBuildProvider.check_github_pat(self.github_pat):
473            self.github_pat = prompt(msg="Github Personal Access Token: ").strip()
474        self.logger.warning("Successfully validated Github personal access token.")
475
476    def process_github_repository(self):
477        while (
478                not self.github_repository or
479                not AzureDevopsBuildProvider.check_github_repository(self.github_pat, self.github_repository)
480        ):
481            self.github_repository = prompt(msg="Github Repository Path (e.g. Azure/azure-cli): ").strip()
482        self.logger.warning("Successfully found Github repository.")
483
484    def process_build_and_release_definition_name(self, scenario):
485        if scenario == 'AZURE_DEVOPS':
486            self.build_definition_name = self.repository_remote_name.replace("_azuredevops_", "_build_", 1)[0:256]
487            self.release_definition_name = self.repository_remote_name.replace("_azuredevops_", "_release_", 1)[0:256]
488        if scenario == 'GITHUB_INTEGRATION':
489            self.build_definition_name = "_build_github_" + self.github_repository.replace("/", "_", 1)[0:256]
490            self.release_definition_name = "_release_github_" + self.github_repository.replace("/", "_", 1)[0:256]
491
492    def process_github_service_endpoint(self):
493        service_endpoints = self.adbp.get_github_service_endpoints(
494            self.organization_name, self.project_name, self.github_repository
495        )
496
497        if not service_endpoints:
498            service_endpoint = self.adbp.create_github_service_endpoint(
499                self.organization_name, self.project_name, self.github_repository, self.github_pat
500            )
501        else:
502            service_endpoint = service_endpoints[0]
503            self.logger.warning("Detected a Github service endpoint already exists: {name}".format(
504                                name=service_endpoint.name))
505
506        self.github_service_endpoint_name = service_endpoint.name
507
508    def process_functionapp_service_endpoint(self, scenario):
509        repository = self.repository_name if scenario == "AZURE_DEVOPS" else self.github_repository
510        service_endpoints = self.adbp.get_service_endpoints(
511            self.organization_name, self.project_name, repository
512        )
513
514        # If there is no matching service endpoint, we need to create a new one
515        if not service_endpoints:
516            try:
517                self.logger.warning("Creating a service principle (this may take a minute or two)")
518                service_endpoint = self.adbp.create_service_endpoint(
519                    self.organization_name, self.project_name, repository
520                )
521            except RoleAssignmentException:
522                if scenario == "AZURE_DEVOPS":
523                    self.adbp.remove_git_remote(self.organization_name, self.project_name, repository)
524                raise CLIError("{ls}To create a build through Azure Pipelines,{ls}"
525                               "The command will assign a contributor role to the "
526                               "Azure function app release service principle.{ls}"
527                               "Please ensure that:{ls}"
528                               "1. You are the owner of the subscription, "
529                               "or have roleAssignments/write permission.{ls}"
530                               "2. You can perform app registration in Azure Active Directory.{ls}"
531                               "3. The combined length of your organization name, project name and repository name "
532                               "is under 68 characters.".format(ls=os.linesep))
533        else:
534            service_endpoint = service_endpoints[0]
535            self.logger.warning("Detected a functionapp service endpoint already exists: {name}".format(
536                                name=service_endpoint.name))
537
538        self.service_endpoint_name = service_endpoint.name
539
540    def process_extensions(self):
541        if self.functionapp_type == LINUX_CONSUMPTION:
542            self.logger.warning("Installing the required extensions for the build and release")
543            self.adbp.create_extension(self.organization_name, 'AzureAppServiceSetAppSettings', 'hboelman')
544            self.adbp.create_extension(self.organization_name, 'PascalNaber-Xpirit-CreateSasToken', 'pascalnaber')
545
546    def process_build(self, scenario):
547        # need to check if the build definition already exists
548        build_definitions = self.adbp.list_build_definitions(self.organization_name, self.project_name)
549        build_definition_match = [
550            build_definition for build_definition in build_definitions
551            if build_definition.name == self.build_definition_name
552        ]
553
554        if not build_definition_match:
555            if scenario == "AZURE_DEVOPS":
556                self.adbp.create_devops_build_definition(self.organization_name, self.project_name,
557                                                         self.repository_name, self.build_definition_name,
558                                                         self.build_pool_name)
559            elif scenario == "GITHUB_INTEGRATION":
560                try:
561                    self.adbp.create_github_build_definition(self.organization_name, self.project_name,
562                                                             self.github_repository, self.build_definition_name,
563                                                             self.build_pool_name)
564                except GithubIntegrationRequestError as gire:
565                    raise CLIError("{error}{ls}{ls}"
566                                   "Please ensure your Github personal access token has sufficient permissions.{ls}{ls}"
567                                   "You may visit https://aka.ms/azure-devops-source-repos for more "
568                                   "information.".format(
569                                       error=gire.message, ls=os.linesep))
570                except GithubContentNotFound:
571                    raise CLIError("Failed to create a webhook for the provided Github repository or "
572                                   "your repository cannot be accessed.{ls}{ls}"
573                                   "You may visit https://aka.ms/azure-devops-source-repos for more "
574                                   "information.".format(
575                                       ls=os.linesep))
576        else:
577            self.logger.warning("Detected a build definition already exists: {name}".format(
578                                name=self.build_definition_name))
579
580        try:
581            self.build = self.adbp.create_build_object(
582                self.organization_name,
583                self.project_name,
584                self.build_definition_name,
585                self.build_pool_name
586            )
587        except BuildErrorException as bee:
588            raise CLIError("{error}{ls}{ls}"
589                           "Please ensure your azure-pipelines.yml file matches Azure function app's "
590                           "runtime operating system and language.{ls}"
591                           "You may use 'az functionapp devops-build create --overwrite-yaml true' "
592                           "to force generating an azure-pipelines.yml specifically for your build".format(
593                               error=bee.message, ls=os.linesep))
594
595        url = "https://dev.azure.com/{org}/{proj}/_build/results?buildId={build_id}".format(
596            org=self.organization_name,
597            proj=self.project_name,
598            build_id=self.build.id
599        )
600        self.logger.warning("The build for the function app has been initiated (this may take a few minutes)")
601        self.logger.warning("To follow the build process go to {url}".format(url=url))
602
603    def wait_for_build(self):
604        build = None
605        prev_log_status = None
606
607        self.logger.info("========== Build Log ==========")
608        while build is None or build.result is None:
609            time.sleep(BUILD_QUERY_FREQUENCY)
610            build = self._get_build_by_id(self.organization_name, self.project_name, self.build.id)
611
612            # Log streaming
613            if self.logger.isEnabledFor(logging.INFO):
614                curr_log_status = self.adbp.get_build_logs_status(
615                    self.organization_name,
616                    self.project_name,
617                    self.build.id
618                )
619                log_content = self.adbp.get_build_logs_content_from_statuses(
620                    organization_name=self.organization_name,
621                    project_name=self.project_name,
622                    build_id=self.build.id,
623                    prev_log=prev_log_status,
624                    curr_log=curr_log_status
625                )
626                if log_content:
627                    self.logger.info(log_content)
628                prev_log_status = curr_log_status
629
630        if build.result == 'failed':
631            url = "https://dev.azure.com/{org}/{proj}/_build/results?buildId={build_id}".format(
632                org=self.organization_name,
633                proj=self.project_name,
634                build_id=build.id
635            )
636            raise CLIError("Sorry, your build has failed in Azure Pipelines.{ls}"
637                           "To view details on why your build has failed please visit {url}".format(
638                               url=url, ls=os.linesep
639                           ))
640        if build.result == 'succeeded':
641            self.logger.warning("Your build has completed.")
642
643    def process_release(self):
644        # need to check if the release definition already exists
645        release_definitions = self.adbp.list_release_definitions(self.organization_name, self.project_name)
646        release_definition_match = [
647            release_definition for release_definition in release_definitions
648            if release_definition.name == self.release_definition_name
649        ]
650
651        if not release_definition_match:
652            self.logger.warning("Composing a release definition...")
653            self.adbp.create_release_definition(self.organization_name, self.project_name,
654                                                self.build_definition_name, self.artifact_name,
655                                                self.release_pool_name, self.service_endpoint_name,
656                                                self.release_definition_name, self.functionapp_type,
657                                                self.functionapp_name, self.storage_name,
658                                                self.resource_group_name, self.settings)
659        else:
660            self.logger.warning("Detected a release definition already exists: {name}".format(
661                                name=self.release_definition_name))
662
663        # Check if a release is automatically triggered. If not, create a new release.
664        time.sleep(RELEASE_COMPOSITION_DELAY)
665        release = self.adbp.get_latest_release(self.organization_name, self.project_name, self.release_definition_name)
666        if release is None:
667            try:
668                release = self.adbp.create_release(
669                    self.organization_name,
670                    self.project_name,
671                    self.release_definition_name
672                )
673            except ReleaseErrorException:
674                raise CLIError("Sorry, your release has failed in Azure Pipelines.{ls}"
675                               "To view details on why your release has failed please visit "
676                               "https://dev.azure.com/{org}/{proj}/_release".format(
677                                   ls=os.linesep, org=self.organization_name, proj=self.project_name
678                               ))
679
680        self.logger.warning("To follow the release process go to "
681                            "https://dev.azure.com/{org}/{proj}/_releaseProgress?"
682                            "_a=release-environment-logs&releaseId={release_id}".format(
683                                org=self.organization_name,
684                                proj=self.project_name,
685                                release_id=release.id
686                            ))
687
688    def _check_if_force_push_required(self, remote_url, remote_branches):
689        force_push_required = False
690        if remote_branches:
691            self.logger.warning("The remote repository is not clean: {url}".format(url=remote_url))
692            self.logger.warning("If you wish to continue, a force push will be commited and "
693                                "your local branches will overwrite the remote branches!")
694            self.logger.warning("Please ensure you have force push permission in {repo} repository.".format(
695                repo=self.repository_name
696            ))
697
698            if self.allow_force_push is None:
699                consent = prompt_y_n("I consent to force push all local branches to Azure DevOps repository")
700            else:
701                consent = str2bool(self.allow_force_push)
702
703            if not consent:
704                self.adbp.remove_git_remote(self.organization_name, self.project_name, self.repository_name)
705                raise CLIError("Failed to obtain your consent.")
706
707            force_push_required = True
708
709        return force_push_required
710
711    def _check_if_git_credential_required(self):
712        # Username and password are not required if git credential manager exists
713        if AzureDevopsBuildProvider.check_git_credential_manager():
714            return
715
716        # Manual setup alternative credential in Azure Devops
717        self.logger.warning("Please visit https://dev.azure.com/{org}/_usersSettings/altcreds".format(
718            org=self.organization_name,
719        ))
720        self.logger.warning('Check "Enable alternate authentication credentials" and save your username and password.')
721        self.logger.warning("You may need to use this credential when pushing your code to Azure DevOps repository.")
722        consent = prompt_y_n("I have setup alternative authentication credentials for {repo}".format(
723            repo=self.repository_name
724        ))
725        if not consent:
726            self.adbp.remove_git_remote(self.organization_name, self.project_name, self.repository_name)
727            raise CLIError("Failed to obtain your consent.")
728
729    def _select_functionapp(self):
730        self.logger.info("Retrieving functionapp names.")
731        functionapps = list_function_app(self.cmd)
732        functionapp_names = sorted([functionapp.name for functionapp in functionapps])
733        if not functionapp_names:
734            raise CLIError("You do not have any existing function apps associated with this account subscription.{ls}"
735                           "1. Please make sure you are logged into the right azure account by "
736                           "running 'az account show' and checking the user.{ls}"
737                           "2. If you are logged in as the right account please check the subscription you are using. "
738                           "Run 'az account show' and view the name.{ls}"
739                           "   If you need to set the subscription run "
740                           "'az account set --subscription <subscription name or id>'{ls}"
741                           "3. If you do not have a function app please create one".format(ls=os.linesep))
742        choice_index = prompt_choice_list('Please select the target function app: ', functionapp_names)
743        functionapp = [functionapp for functionapp in functionapps
744                       if functionapp.name == functionapp_names[choice_index]][0]
745        self.logger.info("Selected functionapp %s", functionapp.name)
746        return functionapp
747
748    def _find_local_repository_runtime_language(self):  # pylint: disable=no-self-use
749        # We want to check that locally the language that they are using matches the type of application they
750        # are deploying to
751        with open('local.settings.json') as f:
752            settings = json.load(f)
753
754        runtime_language = settings.get('Values', {}).get('FUNCTIONS_WORKER_RUNTIME')
755        if not runtime_language:
756            raise CLIError("The 'FUNCTIONS_WORKER_RUNTIME' setting is not defined in the local.settings.json file")
757
758        if SUPPORTED_LANGUAGES.get(runtime_language) is not None:
759            return runtime_language
760
761        raise LanguageNotSupportException(runtime_language)
762
763    def _find_github_repository_runtime_language(self):
764        try:
765            github_file_content = AzureDevopsBuildProvider.get_github_content(
766                self.github_pat,
767                self.github_repository,
768                "local.settings.json"
769            )
770        except GithubContentNotFound:
771            self.logger.warning("The local.settings.json is not commited to Github repository {repo}".format(
772                repo=self.github_repository
773            ))
774            self.logger.warning("Set azure-pipeline.yml language to: {language}".format(
775                language=self.functionapp_language
776            ))
777            return None
778
779        runtime_language = github_file_content.get('Values', {}).get('FUNCTIONS_WORKER_RUNTIME')
780        if not runtime_language:
781            raise CLIError("The 'FUNCTIONS_WORKER_RUNTIME' setting is not defined in the local.settings.json file")
782
783        if SUPPORTED_LANGUAGES.get(runtime_language) is not None:
784            return runtime_language
785
786        raise LanguageNotSupportException(runtime_language)
787
788    def _get_functionapp_runtime_language(self, app_settings):  # pylint: disable=no-self-use
789        functions_worker_runtime = [
790            setting['value'] for setting in app_settings if setting['name'] == "FUNCTIONS_WORKER_RUNTIME"
791        ]
792
793        if functions_worker_runtime:
794            functionapp_language = functions_worker_runtime[0]
795            if SUPPORTED_LANGUAGES.get(functionapp_language) is not None:
796                return SUPPORTED_LANGUAGES[functionapp_language]
797
798            raise LanguageNotSupportException(functionapp_language)
799        return None
800
801    def _get_functionapp_storage_name(self, app_settings):  # pylint: disable=no-self-use
802        functions_worker_runtime = [
803            setting['value'] for setting in app_settings if setting['name'] == "AzureWebJobsStorage"
804        ]
805
806        if functions_worker_runtime:
807            return functions_worker_runtime[0].split(';')[1].split('=')[1]
808        return None
809
810    def _find_type(self, kinds):  # pylint: disable=no-self-use
811        if 'linux' in kinds:
812            if 'container' in kinds:
813                functionapp_type = LINUX_DEDICATED
814            else:
815                functionapp_type = LINUX_CONSUMPTION
816        else:
817            functionapp_type = WINDOWS
818        return functionapp_type
819
820    def _select_organization(self):
821        organizations = self.adbp.list_organizations()
822        organization_names = sorted([organization.accountName for organization in organizations.value])
823        if not organization_names:
824            self.logger.warning("There are no existing organizations, you need to create a new organization.")
825            self._create_organization()
826            self.created_organization = True
827        else:
828            choice_index = prompt_choice_list('Please choose the organization: ', organization_names)
829            organization_match = [organization for organization in organizations.value
830                                  if organization.accountName == organization_names[choice_index]][0]
831            self.organization_name = organization_match.accountName
832
833    def _get_organization_by_name(self, organization_name):
834        organizations = self.adbp.list_organizations()
835        return [organization for organization in organizations.value
836                if organization.accountName == organization_name][0]
837
838    def _create_organization(self):
839        self.logger.info("Starting process to create a new Azure DevOps organization")
840        regions = self.adbp.list_regions()
841        region_names = sorted([region.display_name for region in regions.value])
842        self.logger.info("The region for an Azure DevOps organization is where the organization will be located. "
843                         "Try locate it near your other resources and your location")
844        choice_index = prompt_choice_list('Please select a region for the new organization: ', region_names)
845        region = [region for region in regions.value if region.display_name == region_names[choice_index]][0]
846
847        while True:
848            organization_name = prompt("Please enter a name for your new organization: ")
849            new_organization = self.adbp.create_organization(organization_name, region.name)
850            if new_organization.valid is False:
851                self.logger.warning(new_organization.message)
852                self.logger.warning("Note: all names must be globally unique")
853            else:
854                break
855
856        self.organization_name = new_organization.name
857
858    def _select_project(self):
859        projects = self.adbp.list_projects(self.organization_name)
860        if projects.count > 0:
861            project_names = sorted([project.name for project in projects.value])
862            choice_index = prompt_choice_list('Please select your project: ', project_names)
863            project = [project for project in projects.value if project.name == project_names[choice_index]][0]
864            self.project_name = project.name
865        else:
866            self.logger.warning("There are no existing projects in this organization. "
867                                "You need to create a new project.")
868            self._create_project()
869
870    def _create_project(self):
871        project_name = prompt("Please enter a name for your new project: ")
872        project = self.adbp.create_project(self.organization_name, project_name)
873        # Keep retrying to create a new project if it fails
874        while not project.valid:
875            self.logger.error(project.message)
876            project_name = prompt("Please enter a name for your new project: ")
877            project = self.adbp.create_project(self.organization_name, project_name)
878
879        self.project_name = project.name
880        self.created_project = True
881
882    def _get_build_by_id(self, organization_name, project_name, build_id):
883        builds = self.adbp.list_build_objects(organization_name, project_name)
884        return next((build for build in builds if build.id == build_id))
885
886
887class CmdSelectors(object):
888
889    def __init__(self, cmd, logger, adbp):
890        self.cmd = cmd
891        self.logger = logger
892        self.adbp = adbp
893
894    def cmd_functionapp(self, functionapp_name):
895        functionapps = list_function_app(self.cmd)
896        functionapp_match = [functionapp for functionapp in functionapps
897                             if functionapp.name == functionapp_name]
898        if not functionapp_match:
899            raise CLIError("Error finding functionapp. "
900                           "Please check that the function app exists by calling 'az functionapp list'")
901
902        return functionapp_match[0]
903
904    def cmd_organization(self, organization_name):
905        organizations = self.adbp.list_organizations()
906        organization_match = [organization for organization in organizations.value
907                              if organization.accountName == organization_name]
908        if not organization_match:
909            raise CLIError("Error finding organization. "
910                           "Please check that the organization exists by "
911                           "navigating to the Azure DevOps portal at dev.azure.com")
912
913        return organization_match[0]
914
915    def cmd_project(self, organization_name, project_name):
916        projects = self.adbp.list_projects(organization_name)
917        project_match = [project for project in projects.value if project.name == project_name]
918
919        if not project_match:
920            raise CLIError("Error finding project. "
921                           "Please check that the project exists by "
922                           "navigating to the Azure DevOps portal at dev.azure.com")
923
924        return project_match[0]
925