From aeb3bf810b7a42174bd19e3642b4dda8ac87054c Mon Sep 17 00:00:00 2001 From: Graham Zuber Date: Thu, 28 Oct 2021 00:19:10 -0700 Subject: [PATCH] [functionapp] Added Azure Functions extension (#3926) * initial commit. * Removed vendored sdks. Cleaned up extension. Added dependencies and tests. * Added azure-functions-devops-build dependency * Improved extension summary, added author details. * Fixed style issue. Added entry to service_name.json. * Fixed codeowners for extension. --- .github/CODEOWNERS | 3 + src/functionapp/HISTORY.rst | 8 + src/functionapp/README.rst | 5 + src/functionapp/azext_functionapp/__init__.py | 31 + src/functionapp/azext_functionapp/_help.py | 28 + src/functionapp/azext_functionapp/_params.py | 31 + .../azext_functionapp/azext_metadata.json | 4 + .../azure_devops_build_interactive.py | 928 ++++++++++++++++++ .../azure_devops_build_provider.py | 416 ++++++++ src/functionapp/azext_functionapp/commands.py | 11 + src/functionapp/azext_functionapp/custom.py | 27 + .../azext_functionapp/tests/__init__.py | 5 + .../tests/latest/__init__.py | 5 + .../latest/test_devops_build_commands.py | 179 ++++ .../test_devops_build_commands_thru_mock.py | 211 ++++ src/functionapp/setup.cfg | 2 + src/functionapp/setup.py | 56 ++ src/service_name.json | 5 + 18 files changed, 1955 insertions(+) create mode 100644 src/functionapp/HISTORY.rst create mode 100644 src/functionapp/README.rst create mode 100644 src/functionapp/azext_functionapp/__init__.py create mode 100644 src/functionapp/azext_functionapp/_help.py create mode 100644 src/functionapp/azext_functionapp/_params.py create mode 100644 src/functionapp/azext_functionapp/azext_metadata.json create mode 100644 src/functionapp/azext_functionapp/azure_devops_build_interactive.py create mode 100644 src/functionapp/azext_functionapp/azure_devops_build_provider.py create mode 100644 src/functionapp/azext_functionapp/commands.py create mode 100644 src/functionapp/azext_functionapp/custom.py create mode 100644 src/functionapp/azext_functionapp/tests/__init__.py create mode 100644 src/functionapp/azext_functionapp/tests/latest/__init__.py create mode 100644 src/functionapp/azext_functionapp/tests/latest/test_devops_build_commands.py create mode 100644 src/functionapp/azext_functionapp/tests/latest/test_devops_build_commands_thru_mock.py create mode 100644 src/functionapp/setup.cfg create mode 100644 src/functionapp/setup.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8ee9a258445..4eec13c1189 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -210,4 +210,7 @@ /src/purview/ @kairu-ms @jsntcy +/src/functionapp/ @gzuber + /src/elastic/ @kairu-ms @jsntcy + diff --git a/src/functionapp/HISTORY.rst b/src/functionapp/HISTORY.rst new file mode 100644 index 00000000000..5838cb2bb15 --- /dev/null +++ b/src/functionapp/HISTORY.rst @@ -0,0 +1,8 @@ +.. :changelog: + +Release History +=============== + +0.1.0 +++++++ +* Initial release. \ No newline at end of file diff --git a/src/functionapp/README.rst b/src/functionapp/README.rst new file mode 100644 index 00000000000..f34785e94ab --- /dev/null +++ b/src/functionapp/README.rst @@ -0,0 +1,5 @@ +Microsoft Azure CLI 'functionapp' Extension +========================================== + +This package is for the 'functionapp' extension. +i.e. 'az functionapp' \ No newline at end of file diff --git a/src/functionapp/azext_functionapp/__init__.py b/src/functionapp/azext_functionapp/__init__.py new file mode 100644 index 00000000000..03bd137fbdb --- /dev/null +++ b/src/functionapp/azext_functionapp/__init__.py @@ -0,0 +1,31 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core import AzCommandsLoader +from azext_functionapp._help import helps # pylint: disable=unused-import + + +class FunctionappCommandsLoader(AzCommandsLoader): + + def __init__(self, cli_ctx=None): + from azure.cli.core.commands import CliCommandType + from azure.cli.core.profiles import ResourceType + functionapp_custom = CliCommandType( + operations_tmpl='azext_functionapp.custom#{}') + super().__init__(cli_ctx=cli_ctx, + custom_command_type=functionapp_custom, + resource_type=ResourceType.MGMT_APPSERVICE) + + def load_command_table(self, args): + from azext_functionapp.commands import load_command_table + load_command_table(self, args) + return self.command_table + + def load_arguments(self, command): + from azext_functionapp._params import load_arguments + load_arguments(self, command) + + +COMMAND_LOADER_CLS = FunctionappCommandsLoader diff --git a/src/functionapp/azext_functionapp/_help.py b/src/functionapp/azext_functionapp/_help.py new file mode 100644 index 00000000000..bd39668be6a --- /dev/null +++ b/src/functionapp/azext_functionapp/_help.py @@ -0,0 +1,28 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.help_files import helps # pylint: disable=unused-import + + +helps['functionapp devops-pipeline'] = """ +type: group +short-summary: Azure Function specific integration with Azure DevOps. Please visit https://aka.ms/functions-azure-devops for more information. +""" + +helps['functionapp devops-pipeline create'] = """ +type: command +short-summary: Create an Azure DevOps pipeline for a function app. +examples: + - name: create an Azure Pipeline to a function app. + text: > + az functionapp devops-pipeline create --functionapp-name FunctionApp + - name: create an Azure Pipeline from a Github function app repository. + text: > + az functionapp devops-pipeline create --github-repository GithubOrganization/GithubRepository --github-pat GithubPersonalAccessToken + - name: create an Azure Pipeline with specific Azure DevOps organization and project + text: > + az functionapp devops-pipeline create --organization-name AzureDevOpsOrganization --project-name AzureDevOpsProject +""" diff --git a/src/functionapp/azext_functionapp/_params.py b/src/functionapp/azext_functionapp/_params.py new file mode 100644 index 00000000000..4b90ecb2935 --- /dev/null +++ b/src/functionapp/azext_functionapp/_params.py @@ -0,0 +1,31 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +# pylint: disable=line-too-long + +from azure.cli.core.local_context import LocalContextAttribute, LocalContextAction +from azure.cli.core.commands.parameters import get_three_state_flag + + +def load_arguments(self, _): + # pylint: disable=line-too-long + # PARAMETER REGISTRATION + + with self.argument_context('functionapp devops-pipeline') as c: + c.argument('functionapp_name', help="Name of the Azure function app that you want to use", required=False, + local_context_attribute=LocalContextAttribute(name='functionapp_name', + actions=[LocalContextAction.GET])) + c.argument('organization_name', help="Name of the Azure DevOps organization that you want to use", + required=False) + c.argument('project_name', help="Name of the Azure DevOps project that you want to use", required=False) + c.argument('repository_name', help="Name of the Azure DevOps repository that you want to use", required=False) + c.argument('overwrite_yaml', help="If you have an existing yaml, should it be overwritten?", + arg_type=get_three_state_flag(return_label=True), required=False) + c.argument('allow_force_push', + help="If Azure DevOps repository is not clean, should it overwrite remote content?", + arg_type=get_three_state_flag(return_label=True), required=False) + c.argument('github_pat', help="Github personal access token for creating pipeline from Github repository", + required=False) + c.argument('github_repository', help="Fullname of your Github repository (e.g. Azure/azure-cli)", + required=False) diff --git a/src/functionapp/azext_functionapp/azext_metadata.json b/src/functionapp/azext_functionapp/azext_metadata.json new file mode 100644 index 00000000000..e95e5ee9c6b --- /dev/null +++ b/src/functionapp/azext_functionapp/azext_metadata.json @@ -0,0 +1,4 @@ +{ + "azext.minCliCoreVersion": "2.0.46", + "azext.isPreview": true +} \ No newline at end of file diff --git a/src/functionapp/azext_functionapp/azure_devops_build_interactive.py b/src/functionapp/azext_functionapp/azure_devops_build_interactive.py new file mode 100644 index 00000000000..ce5f5694bfa --- /dev/null +++ b/src/functionapp/azext_functionapp/azure_devops_build_interactive.py @@ -0,0 +1,928 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import time +import json +import logging +from knack.prompting import prompt_choice_list, prompt_y_n, prompt +from knack.util import CLIError +from azure_functions_devops_build.constants import ( + LINUX_CONSUMPTION, + LINUX_DEDICATED, + WINDOWS, + PYTHON, + NODE, + DOTNET, + POWERSHELL +) +from azure_functions_devops_build.exceptions import ( + GitOperationException, + RoleAssignmentException, + LanguageNotSupportException, + BuildErrorException, + ReleaseErrorException, + GithubContentNotFound, + GithubUnauthorizedError, + GithubIntegrationRequestError +) +from azure.cli.command_modules.appservice.custom import ( + list_function_app, + show_functionapp, + get_app_settings) +from azure.cli.command_modules.appservice.utils import str2bool +from .azure_devops_build_provider import AzureDevopsBuildProvider + +# pylint: disable=too-many-instance-attributes,too-many-public-methods + +SUPPORTED_SCENARIOS = ['AZURE_DEVOPS', 'GITHUB_INTEGRATION'] +SUPPORTED_SOURCECODE_LOCATIONS = ['Current Directory', 'Github'] +SUPPORTED_LANGUAGES = { + 'python': PYTHON, + 'node': NODE, + 'dotnet': DOTNET, + 'powershell': POWERSHELL, +} +BUILD_QUERY_FREQUENCY = 5 # sec +RELEASE_COMPOSITION_DELAY = 1 # sec + + +class AzureDevopsBuildInteractive(): + """Implement the basic user flow for a new user wanting to do an Azure DevOps build for Azure Functions + + Attributes: + cmd : the cmd input from the command line + logger : a knack logger to log the info/error messages + """ + + def __init__(self, cmd, logger, functionapp_name, organization_name, project_name, repository_name, + overwrite_yaml, allow_force_push, github_pat, github_repository): + self.adbp = AzureDevopsBuildProvider(cmd.cli_ctx) + self.cmd = cmd + self.logger = logger + self.cmd_selector = CmdSelectors(cmd, logger, self.adbp) + self.functionapp_name = functionapp_name + self.storage_name = None + self.resource_group_name = None + self.functionapp_language = None + self.functionapp_type = None + self.organization_name = organization_name + self.project_name = project_name + self.repository_name = repository_name + + self.github_pat = github_pat + self.github_repository = github_repository + self.github_service_endpoint_name = None + + self.repository_remote_name = None + self.service_endpoint_name = None + self.build_definition_name = None + self.release_definition_name = None + self.build_pool_name = "Default" + self.release_pool_name = "Hosted VS2017" + self.artifact_name = "drop" + + self.settings = [] + self.build = None + # These are used to tell if we made new objects + self.scenario = None # see SUPPORTED_SCENARIOS + self.created_organization = False + self.created_project = False + self.overwrite_yaml = str2bool(overwrite_yaml) + self.allow_force_push = allow_force_push + + def interactive_azure_devops_build(self): + """Main interactive flow which is the only function that should be used outside of this + class (the rest are helpers)""" + + scenario = self.check_scenario() + if scenario == 'AZURE_DEVOPS': + return self.azure_devops_flow() + if scenario == 'GITHUB_INTEGRATION': + return self.github_flow() + raise CLIError('Unknown scenario') + + def azure_devops_flow(self): + self.process_functionapp() + self.pre_checks_azure_devops() + self.process_organization() + self.process_project() + self.process_yaml_local() + self.process_local_repository() + self.process_remote_repository() + self.process_functionapp_service_endpoint('AZURE_DEVOPS') + self.process_extensions() + self.process_build_and_release_definition_name('AZURE_DEVOPS') + self.process_build('AZURE_DEVOPS') + self.wait_for_build() + self.process_release() + self.logger.warning("Pushing your code to {remote}:master will now trigger another build.".format( + remote=self.repository_remote_name + )) + return { + 'source_location': 'local', + 'functionapp_name': self.functionapp_name, + 'storage_name': self.storage_name, + 'resource_group_name': self.resource_group_name, + 'functionapp_language': self.functionapp_language, + 'functionapp_type': self.functionapp_type, + 'organization_name': self.organization_name, + 'project_name': self.project_name, + 'repository_name': self.repository_name, + 'service_endpoint_name': self.service_endpoint_name, + 'build_definition_name': self.build_definition_name, + 'release_definition_name': self.release_definition_name + } + + def github_flow(self): + self.process_github_personal_access_token() + self.process_github_repository() + self.process_functionapp() + self.pre_checks_github() + self.process_organization() + self.process_project() + self.process_yaml_github() + self.process_functionapp_service_endpoint('GITHUB_INTEGRATION') + self.process_github_service_endpoint() + self.process_extensions() + self.process_build_and_release_definition_name('GITHUB_INTEGRATION') + self.process_build('GITHUB_INTEGRATION') + self.wait_for_build() + self.process_release() + self.logger.warning("Setup continuous integration between {github_repo} and Azure Pipelines".format( + github_repo=self.github_repository + )) + return { + 'source_location': 'Github', + 'functionapp_name': self.functionapp_name, + 'storage_name': self.storage_name, + 'resource_group_name': self.resource_group_name, + 'functionapp_language': self.functionapp_language, + 'functionapp_type': self.functionapp_type, + 'organization_name': self.organization_name, + 'project_name': self.project_name, + 'repository_name': self.github_repository, + 'service_endpoint_name': self.service_endpoint_name, + 'build_definition_name': self.build_definition_name, + 'release_definition_name': self.release_definition_name + } + + def check_scenario(self): + if self.repository_name: + self.scenario = 'AZURE_DEVOPS' + elif self.github_pat or self.github_repository: + self.scenario = 'GITHUB_INTEGRATION' + else: + choice_index = prompt_choice_list( + 'Please choose Azure function source code location: ', + SUPPORTED_SOURCECODE_LOCATIONS + ) + self.scenario = SUPPORTED_SCENARIOS[choice_index] + return self.scenario + + def pre_checks_azure_devops(self): + if not AzureDevopsBuildProvider.check_git(): + raise CLIError("The program requires git source control to operate, please install git.") + + if not os.path.exists('host.json'): + raise CLIError("There is no host.json in the current directory.{ls}" + "Functionapps must contain a host.json in their root".format(ls=os.linesep)) + + if not os.path.exists('local.settings.json'): + raise CLIError("There is no local.settings.json in the current directory.{ls}" + "Functionapps must contain a local.settings.json in their root".format(ls=os.linesep)) + + try: + local_runtime_language = self._find_local_repository_runtime_language() + except LanguageNotSupportException as lnse: + raise CLIError("Sorry, currently we do not support {language}.".format(language=lnse.message)) from lnse + + if local_runtime_language != self.functionapp_language: + raise CLIError("The language stack setting found in your local repository ({setting}) does not match " + "the language stack of your Azure function app in Azure ({functionapp}).{ls}" + "Please look at the FUNCTIONS_WORKER_RUNTIME setting both in your local.settings.json file " + "and in your Azure function app's application settings, " + "and ensure they match.".format( + setting=local_runtime_language, + functionapp=self.functionapp_language, + ls=os.linesep, + )) + + def pre_checks_github(self): + if not AzureDevopsBuildProvider.check_github_file(self.github_pat, self.github_repository, 'host.json'): + raise CLIError("There is no host.json file in the provided Github repository {repo}.{ls}" + "Each function app must contain a host.json in their root.{ls}" + "Please ensure you have read permission to the repository.".format( + repo=self.github_repository, + ls=os.linesep, + )) + try: + github_runtime_language = self._find_github_repository_runtime_language() + except LanguageNotSupportException as lnse: + raise CLIError("Sorry, currently we do not support {language}.".format(language=lnse.message)) from lnse + + if github_runtime_language is not None and github_runtime_language != self.functionapp_language: + raise CLIError("The language stack setting found in the provided repository ({setting}) does not match " + "the language stack of your Azure function app ({functionapp}).{ls}" + "Please look at the FUNCTIONS_WORKER_RUNTIME setting both in your local.settings.json file " + "and in your Azure function app's application settings, " + "and ensure they match.".format( + setting=github_runtime_language, + functionapp=self.functionapp_language, + ls=os.linesep, + )) + + def process_functionapp(self): + """Helper to retrieve information about a functionapp""" + if self.functionapp_name is None: + functionapp = self._select_functionapp() + self.functionapp_name = functionapp.name + else: + functionapp = self.cmd_selector.cmd_functionapp(self.functionapp_name) + + kinds = show_functionapp(self.cmd, functionapp.resource_group, functionapp.name).kind.split(',') + + # Get functionapp settings in Azure + app_settings = get_app_settings(self.cmd, functionapp.resource_group, functionapp.name) + + self.resource_group_name = functionapp.resource_group + self.functionapp_type = self._find_type(kinds) + + try: + self.functionapp_language = self._get_functionapp_runtime_language(app_settings) + self.storage_name = self._get_functionapp_storage_name(app_settings) + except LanguageNotSupportException as lnse: + raise CLIError("Sorry, currently we do not support {language}.".format(language=lnse.message)) from lnse + + def process_organization(self): + """Helper to retrieve information about an organization / create a new one""" + if self.organization_name is None: + response = prompt_y_n('Would you like to use an existing Azure DevOps organization? ') + if response: + self._select_organization() + else: + self._create_organization() + self.created_organization = True + else: + self.cmd_selector.cmd_organization(self.organization_name) + + def process_project(self): + """Helper to retrieve information about a project / create a new one""" + # There is a new organization so a new project will be needed + if (self.project_name is None) and (self.created_organization): + self._create_project() + elif self.project_name is None: + use_existing_project = prompt_y_n('Would you like to use an existing Azure DevOps project? ') + if use_existing_project: + self._select_project() + else: + self._create_project() + else: + self.cmd_selector.cmd_project(self.organization_name, self.project_name) + + self.logger.warning("To view your Azure DevOps project, " + "please visit https://dev.azure.com/{org}/{proj}".format( + org=self.organization_name, + proj=self.project_name + )) + + def process_yaml_local(self): + """Helper to create the local azure-pipelines.yml file""" + + if os.path.exists('azure-pipelines.yml'): + if self.overwrite_yaml is None: + self.logger.warning("There is already an azure-pipelines.yml file in your local repository.") + self.logger.warning("If you are using a yaml file that was not configured " + "through this command, this command may fail.") + response = prompt_y_n("Do you want to delete it and create a new one? ") + else: + response = self.overwrite_yaml + + if (not os.path.exists('azure-pipelines.yml')) or response: + self.logger.warning('Creating a new azure-pipelines.yml') + try: + self.adbp.create_yaml(self.functionapp_language, self.functionapp_type) + except LanguageNotSupportException as lnse: + raise CLIError("Sorry, currently we do not support {language}.".format(language=lnse.message)) from lnse + + def process_yaml_github(self): + does_yaml_file_exist = AzureDevopsBuildProvider.check_github_file( + self.github_pat, + self.github_repository, + "azure-pipelines.yml" + ) + if does_yaml_file_exist and self.overwrite_yaml is None: + self.logger.warning("There is already an azure-pipelines.yml file in the provided Github repository.") + self.logger.warning("If you are using a yaml file that was not configured " + "through this command, this command may fail.") + self.overwrite_yaml = prompt_y_n("Do you want to generate a new one? " + "(It will be committed to the master branch of the provided repository)") + + # Create and commit the new yaml file to Github without asking + if not does_yaml_file_exist: + if self.github_repository: + self.logger.warning("Creating a new azure-pipelines.yml for Github repository") + try: + AzureDevopsBuildProvider.create_github_yaml( + pat=self.github_pat, + language=self.functionapp_language, + app_type=self.functionapp_type, + repository_fullname=self.github_repository + ) + except LanguageNotSupportException as lnse: + raise CLIError("Sorry, currently this command does not support {language}. To proceed, " + "you'll need to configure your build manually at dev.azure.com".format( + language=lnse.message)) from lnse + except GithubContentNotFound as gcnf: + raise CLIError("Sorry, the repository you provided does not exist or " + "you do not have sufficient permissions to write to the repository. " + "Please provide an access token with the proper permissions.") from gcnf + except GithubUnauthorizedError as gue: + raise CLIError("Sorry, you do not have sufficient permissions to commit " + "azure-pipelines.yml to your Github repository.") from gue + + # Overwrite yaml file + if does_yaml_file_exist and self.overwrite_yaml: + self.logger.warning("Overwrite azure-pipelines.yml file in the provided Github repository") + try: + AzureDevopsBuildProvider.create_github_yaml( + pat=self.github_pat, + language=self.functionapp_language, + app_type=self.functionapp_type, + repository_fullname=self.github_repository, + overwrite=True + ) + except LanguageNotSupportException as lnse: + raise CLIError("Sorry, currently this command does not support {language}. To proceed, " + "you'll need to configure your build manually at dev.azure.com".format( + language=lnse.message)) from lnse + except GithubContentNotFound as gcnf: + raise CLIError("Sorry, the repository you provided does not exist or " + "you do not have sufficient permissions to write to the repository. " + "Please provide an access token with the proper permissions.") from gcnf + except GithubUnauthorizedError as gue: + raise CLIError("Sorry, you do not have sufficient permissions to overwrite " + "azure-pipelines.yml in your Github repository.") from gue + + def process_local_repository(self): + has_local_git_repository = AzureDevopsBuildProvider.check_git_local_repository() + if has_local_git_repository: + self.logger.warning("Detected a local Git repository already exists.") + + # Collect repository name on Azure Devops + if not self.repository_name: + self.repository_name = prompt("Push to which Azure DevOps repository (default: {repo}): ".format( + repo=self.project_name + )) + if not self.repository_name: # Select default value + self.repository_name = self.project_name + + expected_remote_name = self.adbp.get_local_git_remote_name( + self.organization_name, + self.project_name, + self.repository_name + ) + expected_remote_url = self.adbp.get_azure_devops_repo_url( + self.organization_name, + self.project_name, + self.repository_name + ) + + # If local repository already has a remote + # Let the user to know s/he can push to the remote directly for context update + # Or let s/he remove the git remote manually + has_local_git_remote = self.adbp.check_git_remote( + self.organization_name, + self.project_name, + self.repository_name + ) + if has_local_git_remote: + raise CLIError("There's a git remote bound to {url}.{ls}" + "To update the repository and trigger an Azure Pipelines build, please use " + "'git push {remote} master'".format( + url=expected_remote_url, + remote=expected_remote_name, + ls=os.linesep) + ) + + # Setup a local git repository and create a new commit on top of this context + try: + self.adbp.setup_local_git_repository(self.organization_name, self.project_name, self.repository_name) + except GitOperationException as goe: + raise CLIError("Failed to setup local git repository when running '{message}'{ls}" + "Please ensure you have setup git user.email and user.name".format( + message=goe.message, ls=os.linesep + )) from goe + + self.repository_remote_name = expected_remote_name + self.logger.warning("Added git remote {remote}".format(remote=expected_remote_name)) + + def process_remote_repository(self): + # Create remote repository if it does not exist + repository = self.adbp.get_azure_devops_repository( + self.organization_name, + self.project_name, + self.repository_name + ) + if not repository: + self.adbp.create_repository(self.organization_name, self.project_name, self.repository_name) + + # Force push branches if repository is not clean + remote_url = self.adbp.get_azure_devops_repo_url( + self.organization_name, + self.project_name, + self.repository_name + ) + remote_branches = self.adbp.get_azure_devops_repository_branches( + self.organization_name, + self.project_name, + self.repository_name + ) + is_force_push = self._check_if_force_push_required(remote_url, remote_branches) + + # Prompt user to generate a git credential + self._check_if_git_credential_required() + + # If the repository does not exist, we will do a normal push + # If the repository exists, we will do a force push + try: + self.adbp.push_local_to_azure_devops_repository( + self.organization_name, + self.project_name, + self.repository_name, + force=is_force_push + ) + except GitOperationException as goe: + self.adbp.remove_git_remote(self.organization_name, self.project_name, self.repository_name) + raise CLIError("Failed to push your local repository to {url}{ls}" + "Please check your credentials and ensure you are a contributor to the repository.".format( + url=remote_url, ls=os.linesep + )) from goe + + self.logger.warning("Local branches have been pushed to {url}".format(url=remote_url)) + + def process_github_personal_access_token(self): + if not self.github_pat: + self.logger.warning("If you need to create a Github Personal Access Token, " + "please follow the steps found at the following link:") + self.logger.warning("https://help.github.com/en/articles/" + "creating-a-personal-access-token-for-the-command-line{ls}".format(ls=os.linesep)) + self.logger.warning("The required Personal Access Token permissions can be found here:") + self.logger.warning("https://aka.ms/azure-devops-source-repos") + + while not self.github_pat or not AzureDevopsBuildProvider.check_github_pat(self.github_pat): + self.github_pat = prompt(msg="Github Personal Access Token: ").strip() + self.logger.warning("Successfully validated Github personal access token.") + + def process_github_repository(self): + while ( + not self.github_repository or + not AzureDevopsBuildProvider.check_github_repository(self.github_pat, self.github_repository) + ): + self.github_repository = prompt(msg="Github Repository Path (e.g. Azure/azure-cli): ").strip() + self.logger.warning("Successfully found Github repository.") + + def process_build_and_release_definition_name(self, scenario): + if scenario == 'AZURE_DEVOPS': + self.build_definition_name = self.repository_remote_name.replace("_azuredevops_", "_build_", 1)[0:256] + self.release_definition_name = self.repository_remote_name.replace("_azuredevops_", "_release_", 1)[0:256] + if scenario == 'GITHUB_INTEGRATION': + self.build_definition_name = "_build_github_" + self.github_repository.replace("/", "_", 1)[0:256] + self.release_definition_name = "_release_github_" + self.github_repository.replace("/", "_", 1)[0:256] + + def process_github_service_endpoint(self): + service_endpoints = self.adbp.get_github_service_endpoints( + self.organization_name, self.project_name, self.github_repository + ) + + if not service_endpoints: + service_endpoint = self.adbp.create_github_service_endpoint( + self.organization_name, self.project_name, self.github_repository, self.github_pat + ) + else: + service_endpoint = service_endpoints[0] + self.logger.warning("Detected a Github service endpoint already exists: {name}".format( + name=service_endpoint.name)) + + self.github_service_endpoint_name = service_endpoint.name + + def process_functionapp_service_endpoint(self, scenario): + repository = self.repository_name if scenario == "AZURE_DEVOPS" else self.github_repository + service_endpoints = self.adbp.get_service_endpoints( + self.organization_name, self.project_name, repository + ) + + # If there is no matching service endpoint, we need to create a new one + if not service_endpoints: + try: + self.logger.warning("Creating a service principle (this may take a minute or two)") + service_endpoint = self.adbp.create_service_endpoint( + self.organization_name, self.project_name, repository + ) + except RoleAssignmentException as rae: + if scenario == "AZURE_DEVOPS": + self.adbp.remove_git_remote(self.organization_name, self.project_name, repository) + raise CLIError("{ls}To create a build through Azure Pipelines,{ls}" + "The command will assign a contributor role to the " + "Azure function app release service principle.{ls}" + "Please ensure that:{ls}" + "1. You are the owner of the subscription, " + "or have roleAssignments/write permission.{ls}" + "2. You can perform app registration in Azure Active Directory.{ls}" + "3. The combined length of your organization name, project name and repository name " + "is under 68 characters.".format(ls=os.linesep)) from rae + else: + service_endpoint = service_endpoints[0] + self.logger.warning("Detected a functionapp service endpoint already exists: {name}".format( + name=service_endpoint.name)) + + self.service_endpoint_name = service_endpoint.name + + def process_extensions(self): + if self.functionapp_type == LINUX_CONSUMPTION: + self.logger.warning("Installing the required extensions for the build and release") + self.adbp.create_extension(self.organization_name, 'AzureAppServiceSetAppSettings', 'hboelman') + self.adbp.create_extension(self.organization_name, 'PascalNaber-Xpirit-CreateSasToken', 'pascalnaber') + + def process_build(self, scenario): + # need to check if the build definition already exists + build_definitions = self.adbp.list_build_definitions(self.organization_name, self.project_name) + build_definition_match = [ + build_definition for build_definition in build_definitions + if build_definition.name == self.build_definition_name + ] + + if not build_definition_match: + if scenario == "AZURE_DEVOPS": + self.adbp.create_devops_build_definition(self.organization_name, self.project_name, + self.repository_name, self.build_definition_name, + self.build_pool_name) + elif scenario == "GITHUB_INTEGRATION": + try: + self.adbp.create_github_build_definition(self.organization_name, self.project_name, + self.github_repository, self.build_definition_name, + self.build_pool_name) + except GithubIntegrationRequestError as gire: + raise CLIError("{error}{ls}{ls}" + "Please ensure your Github personal access token has sufficient permissions.{ls}{ls}" + "You may visit https://aka.ms/azure-devops-source-repos for more " + "information.".format( + error=gire.message, ls=os.linesep)) from gire + except GithubContentNotFound as gcnf: + raise CLIError("Failed to create a webhook for the provided Github repository or " + "your repository cannot be accessed.{ls}{ls}" + "You may visit https://aka.ms/azure-devops-source-repos for more " + "information.".format( + ls=os.linesep)) from gcnf + else: + self.logger.warning("Detected a build definition already exists: {name}".format( + name=self.build_definition_name)) + + try: + self.build = self.adbp.create_build_object( + self.organization_name, + self.project_name, + self.build_definition_name, + self.build_pool_name + ) + except BuildErrorException as bee: + raise CLIError("{error}{ls}{ls}" + "Please ensure your azure-pipelines.yml file matches Azure function app's " + "runtime operating system and language.{ls}" + "You may use 'az functionapp devops-build create --overwrite-yaml true' " + "to force generating an azure-pipelines.yml specifically for your build".format( + error=bee.message, ls=os.linesep)) from bee + + url = "https://dev.azure.com/{org}/{proj}/_build/results?buildId={build_id}".format( + org=self.organization_name, + proj=self.project_name, + build_id=self.build.id + ) + self.logger.warning("The build for the function app has been initiated (this may take a few minutes)") + self.logger.warning("To follow the build process go to {url}".format(url=url)) + + def wait_for_build(self): + build = None + prev_log_status = None + + self.logger.info("========== Build Log ==========") + while build is None or build.result is None: + time.sleep(BUILD_QUERY_FREQUENCY) + build = self._get_build_by_id(self.organization_name, self.project_name, self.build.id) + + # Log streaming + if self.logger.isEnabledFor(logging.INFO): + curr_log_status = self.adbp.get_build_logs_status( + self.organization_name, + self.project_name, + self.build.id + ) + log_content = self.adbp.get_build_logs_content_from_statuses( + organization_name=self.organization_name, + project_name=self.project_name, + build_id=self.build.id, + prev_log=prev_log_status, + curr_log=curr_log_status + ) + if log_content: + self.logger.info(log_content) + prev_log_status = curr_log_status + + if build.result == 'failed': + url = "https://dev.azure.com/{org}/{proj}/_build/results?buildId={build_id}".format( + org=self.organization_name, + proj=self.project_name, + build_id=build.id + ) + raise CLIError("Sorry, your build has failed in Azure Pipelines.{ls}" + "To view details on why your build has failed please visit {url}".format( + url=url, ls=os.linesep + )) + if build.result == 'succeeded': + self.logger.warning("Your build has completed.") + + def process_release(self): + # need to check if the release definition already exists + release_definitions = self.adbp.list_release_definitions(self.organization_name, self.project_name) + release_definition_match = [ + release_definition for release_definition in release_definitions + if release_definition.name == self.release_definition_name + ] + + if not release_definition_match: + self.logger.warning("Composing a release definition...") + self.adbp.create_release_definition(self.organization_name, self.project_name, + self.build_definition_name, self.artifact_name, + self.release_pool_name, self.service_endpoint_name, + self.release_definition_name, self.functionapp_type, + self.functionapp_name, self.storage_name, + self.resource_group_name, self.settings) + else: + self.logger.warning("Detected a release definition already exists: {name}".format( + name=self.release_definition_name)) + + # Check if a release is automatically triggered. If not, create a new release. + time.sleep(RELEASE_COMPOSITION_DELAY) + release = self.adbp.get_latest_release(self.organization_name, self.project_name, self.release_definition_name) + if release is None: + try: + release = self.adbp.create_release( + self.organization_name, + self.project_name, + self.release_definition_name + ) + except ReleaseErrorException as ree: + raise CLIError("Sorry, your release has failed in Azure Pipelines.{ls}" + "To view details on why your release has failed please visit " + "https://dev.azure.com/{org}/{proj}/_release".format( + ls=os.linesep, org=self.organization_name, proj=self.project_name + )) from ree + + self.logger.warning("To follow the release process go to " + "https://dev.azure.com/{org}/{proj}/_releaseProgress?" + "_a=release-environment-logs&releaseId={release_id}".format( + org=self.organization_name, + proj=self.project_name, + release_id=release.id + )) + + def _check_if_force_push_required(self, remote_url, remote_branches): + force_push_required = False + if remote_branches: + self.logger.warning("The remote repository is not clean: {url}".format(url=remote_url)) + self.logger.warning("If you wish to continue, a force push will be commited and " + "your local branches will overwrite the remote branches!") + self.logger.warning("Please ensure you have force push permission in {repo} repository.".format( + repo=self.repository_name + )) + + if self.allow_force_push is None: + consent = prompt_y_n("I consent to force push all local branches to Azure DevOps repository") + else: + consent = str2bool(self.allow_force_push) + + if not consent: + self.adbp.remove_git_remote(self.organization_name, self.project_name, self.repository_name) + raise CLIError("Failed to obtain your consent.") + + force_push_required = True + + return force_push_required + + def _check_if_git_credential_required(self): + # Username and password are not required if git credential manager exists + if AzureDevopsBuildProvider.check_git_credential_manager(): + return + + # Manual setup alternative credential in Azure Devops + self.logger.warning("Please visit https://dev.azure.com/{org}/_usersSettings/altcreds".format( + org=self.organization_name, + )) + self.logger.warning('Check "Enable alternate authentication credentials" and save your username and password.') + self.logger.warning("You may need to use this credential when pushing your code to Azure DevOps repository.") + consent = prompt_y_n("I have setup alternative authentication credentials for {repo}".format( + repo=self.repository_name + )) + if not consent: + self.adbp.remove_git_remote(self.organization_name, self.project_name, self.repository_name) + raise CLIError("Failed to obtain your consent.") + + def _select_functionapp(self): + self.logger.info("Retrieving functionapp names.") + functionapps = list_function_app(self.cmd) + functionapp_names = sorted([functionapp.name for functionapp in functionapps]) + if not functionapp_names: + raise CLIError("You do not have any existing function apps associated with this account subscription.{ls}" + "1. Please make sure you are logged into the right azure account by " + "running 'az account show' and checking the user.{ls}" + "2. If you are logged in as the right account please check the subscription you are using. " + "Run 'az account show' and view the name.{ls}" + " If you need to set the subscription run " + "'az account set --subscription '{ls}" + "3. If you do not have a function app please create one".format(ls=os.linesep)) + choice_index = prompt_choice_list('Please select the target function app: ', functionapp_names) + functionapp = [functionapp for functionapp in functionapps + if functionapp.name == functionapp_names[choice_index]][0] + self.logger.info("Selected functionapp %s", functionapp.name) + return functionapp + + def _find_local_repository_runtime_language(self): # pylint: disable=no-self-use + # We want to check that locally the language that they are using matches the type of application they + # are deploying to + with open('local.settings.json') as f: + settings = json.load(f) + + runtime_language = settings.get('Values', {}).get('FUNCTIONS_WORKER_RUNTIME') + if not runtime_language: + raise CLIError("The 'FUNCTIONS_WORKER_RUNTIME' setting is not defined in the local.settings.json file") + + if SUPPORTED_LANGUAGES.get(runtime_language) is not None: + return runtime_language + + raise LanguageNotSupportException(runtime_language) + + def _find_github_repository_runtime_language(self): + try: + github_file_content = AzureDevopsBuildProvider.get_github_content( + self.github_pat, + self.github_repository, + "local.settings.json" + ) + except GithubContentNotFound: + self.logger.warning("The local.settings.json is not commited to Github repository {repo}".format( + repo=self.github_repository + )) + self.logger.warning("Set azure-pipeline.yml language to: {language}".format( + language=self.functionapp_language + )) + return None + + runtime_language = github_file_content.get('Values', {}).get('FUNCTIONS_WORKER_RUNTIME') + if not runtime_language: + raise CLIError("The 'FUNCTIONS_WORKER_RUNTIME' setting is not defined in the local.settings.json file") + + if SUPPORTED_LANGUAGES.get(runtime_language) is not None: + return runtime_language + + raise LanguageNotSupportException(runtime_language) + + def _get_functionapp_runtime_language(self, app_settings): # pylint: disable=no-self-use + functions_worker_runtime = [ + setting['value'] for setting in app_settings if setting['name'] == "FUNCTIONS_WORKER_RUNTIME" + ] + + if functions_worker_runtime: + functionapp_language = functions_worker_runtime[0] + if SUPPORTED_LANGUAGES.get(functionapp_language) is not None: + return SUPPORTED_LANGUAGES[functionapp_language] + + raise LanguageNotSupportException(functionapp_language) + return None + + def _get_functionapp_storage_name(self, app_settings): # pylint: disable=no-self-use + functions_worker_runtime = [ + setting['value'] for setting in app_settings if setting['name'] == "AzureWebJobsStorage" + ] + + if functions_worker_runtime: + return functions_worker_runtime[0].split(';')[1].split('=')[1] + return None + + def _find_type(self, kinds): # pylint: disable=no-self-use + if 'linux' in kinds: + if 'container' in kinds: + functionapp_type = LINUX_DEDICATED + else: + functionapp_type = LINUX_CONSUMPTION + else: + functionapp_type = WINDOWS + return functionapp_type + + def _select_organization(self): + organizations = self.adbp.list_organizations() + organization_names = sorted([organization.accountName for organization in organizations.value]) + if not organization_names: + self.logger.warning("There are no existing organizations, you need to create a new organization.") + self._create_organization() + self.created_organization = True + else: + choice_index = prompt_choice_list('Please choose the organization: ', organization_names) + organization_match = [organization for organization in organizations.value + if organization.accountName == organization_names[choice_index]][0] + self.organization_name = organization_match.accountName + + def _get_organization_by_name(self, organization_name): + organizations = self.adbp.list_organizations() + return [organization for organization in organizations.value + if organization.accountName == organization_name][0] + + def _create_organization(self): + self.logger.info("Starting process to create a new Azure DevOps organization") + regions = self.adbp.list_regions() + region_names = sorted([region.display_name for region in regions.value]) + self.logger.info("The region for an Azure DevOps organization is where the organization will be located. " + "Try locate it near your other resources and your location") + choice_index = prompt_choice_list('Please select a region for the new organization: ', region_names) + region = [region for region in regions.value if region.display_name == region_names[choice_index]][0] + + while True: + organization_name = prompt("Please enter a name for your new organization: ") + new_organization = self.adbp.create_organization(organization_name, region.name) + print(dir(new_organization)) + if new_organization.valid is False: + self.logger.warning(new_organization.message) + self.logger.warning("Note: all names must be globally unique") + else: + break + + self.organization_name = new_organization["name"] + + def _select_project(self): + projects = self.adbp.list_projects(self.organization_name) + if projects.count > 0: + project_names = sorted([project.name for project in projects.value]) + choice_index = prompt_choice_list('Please select your project: ', project_names) + project = [project for project in projects.value if project.name == project_names[choice_index]][0] + self.project_name = project.name + else: + self.logger.warning("There are no existing projects in this organization. " + "You need to create a new project.") + self._create_project() + + def _create_project(self): + project_name = prompt("Please enter a name for your new project: ") + project = self.adbp.create_project(self.organization_name, project_name) + # Keep retrying to create a new project if it fails + while not project.valid: + self.logger.error(project.message) + project_name = prompt("Please enter a name for your new project: ") + project = self.adbp.create_project(self.organization_name, project_name) + + self.project_name = project.name + self.created_project = True + + def _get_build_by_id(self, organization_name, project_name, build_id): + builds = self.adbp.list_build_objects(organization_name, project_name) + return next((build for build in builds if build.id == build_id)) + + +class CmdSelectors(): + + def __init__(self, cmd, logger, adbp): + self.cmd = cmd + self.logger = logger + self.adbp = adbp + + def cmd_functionapp(self, functionapp_name): + functionapps = list_function_app(self.cmd) + functionapp_match = [functionapp for functionapp in functionapps + if functionapp.name == functionapp_name] + if not functionapp_match: + raise CLIError("Error finding functionapp. " + "Please check that the function app exists by calling 'az functionapp list'") + + return functionapp_match[0] + + def cmd_organization(self, organization_name): + organizations = self.adbp.list_organizations() + organization_match = [organization for organization in organizations.value + if organization.accountName == organization_name] + if not organization_match: + raise CLIError("Error finding organization. " + "Please check that the organization exists by " + "navigating to the Azure DevOps portal at dev.azure.com") + + return organization_match[0] + + def cmd_project(self, organization_name, project_name): + projects = self.adbp.list_projects(organization_name) + project_match = [project for project in projects.value if project.name == project_name] + + if not project_match: + raise CLIError("Error finding project. " + "Please check that the project exists by " + "navigating to the Azure DevOps portal at dev.azure.com") + + return project_match[0] diff --git a/src/functionapp/azext_functionapp/azure_devops_build_provider.py b/src/functionapp/azext_functionapp/azure_devops_build_provider.py new file mode 100644 index 00000000000..2c485650253 --- /dev/null +++ b/src/functionapp/azext_functionapp/azure_devops_build_provider.py @@ -0,0 +1,416 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure_functions_devops_build.organization.organization_manager import OrganizationManager +from azure_functions_devops_build.project.project_manager import ProjectManager +from azure_functions_devops_build.yaml.yaml_manager import YamlManager +from azure_functions_devops_build.repository.repository_manager import RepositoryManager +from azure_functions_devops_build.pool.pool_manager import PoolManager +from azure_functions_devops_build.service_endpoint.service_endpoint_manager import ServiceEndpointManager +from azure_functions_devops_build.extension.extension_manager import ExtensionManager +from azure_functions_devops_build.builder.builder_manager import BuilderManager +from azure_functions_devops_build.artifact.artifact_manager import ArtifactManager +from azure_functions_devops_build.release.release_manager import ReleaseManager + +from azure_functions_devops_build.yaml.github_yaml_manager import GithubYamlManager +from azure_functions_devops_build.repository.github_repository_manager import GithubRepositoryManager +from azure_functions_devops_build.user.github_user_manager import GithubUserManager +from azure_functions_devops_build.service_endpoint.github_service_endpoint_manager import GithubServiceEndpointManager + +from azure.cli.core._profile import Profile + + +class AzureDevopsBuildProvider(): # pylint: disable=too-many-public-methods + """Implement a wrapper surrounding the different azure_functions_devops_build commands + + Attributes: + cred : the credentials needed to access the classes + """ + def __init__(self, cli_ctx): + profile = Profile(cli_ctx=cli_ctx) + self._creds, _, _ = profile.get_login_credentials(subscription_id=None) + + def list_organizations(self): + """List DevOps organizations""" + organization_manager = OrganizationManager(creds=self._creds) + organizations = organization_manager.list_organizations() + return organizations + + def list_regions(self): + """List DevOps regions""" + organization_manager = OrganizationManager(creds=self._creds) + regions = organization_manager.list_regions() + return regions + + def create_organization(self, organization_name, regionCode): + """Create DevOps organization""" + # validate the organization name + organization_manager = OrganizationManager(creds=self._creds) + print("1") + validation = organization_manager.validate_organization_name(organization_name) + if not validation.valid: + return validation + print("2") + + # validate region code: + valid_region = False + for region in self.list_regions().value: + if region.name == regionCode: + valid_region = True + print("3") + if not valid_region: + validation.message = "not a valid region code - run 'az functionapp devops-build organization' regions to find a valid regionCode" # pylint: disable=line-too-long + validation.valid = False + return validation + print("4") + + new_organization = organization_manager.create_organization(regionCode, organization_name) + print("5") + new_organization.valid = True + return new_organization + + def list_projects(self, organization_name): + """List DevOps projects""" + project_manager = ProjectManager(organization_name=organization_name, creds=self._creds) + projects = project_manager.list_projects() + return projects + + def create_project(self, organization_name, project_name): + """Create DevOps project""" + project_manager = ProjectManager(organization_name=organization_name, creds=self._creds) + project = project_manager.create_project(project_name) + return project + + def create_yaml(self, language, appType): # pylint: disable=no-self-use + """Create azure pipelines yaml""" + yaml_manager = YamlManager(language, appType) + yaml_manager.create_yaml() + + def create_repository(self, organization_name, project_name, repository_name): + """Create devops repository""" + repository_manager = RepositoryManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return repository_manager.create_repository(repository_name) + + def list_repositories(self, organization_name, project_name): + """List devops repository""" + repository_manager = RepositoryManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return repository_manager.list_repositories() + + def list_commits(self, organization_name, project_name, repository_name): + """List devops commits""" + repository_manager = RepositoryManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return repository_manager.list_commits(repository_name) + + @staticmethod + def check_git(): + """Check if git command does exist""" + return RepositoryManager.check_git() + + @staticmethod + def check_git_local_repository(): + """Check if local git repository does exist""" + return RepositoryManager.check_git_local_repository() + + @staticmethod + def check_git_credential_manager(): + return RepositoryManager.check_git_credential_manager() + + def check_git_remote(self, organization_name, project_name, repository_name): + """Check if local git remote name does exist""" + repository_manager = RepositoryManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return repository_manager.check_git_remote(repository_name, remote_prefix="azuredevops") + + def remove_git_remote(self, organization_name, project_name, repository_name): + repository_manager = RepositoryManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return repository_manager.remove_git_remote(repository_name, remote_prefix="azuredevops") + + def get_local_git_remote_name(self, organization_name, project_name, repository_name): + """Get the local git remote name for current repository""" + repository_manager = RepositoryManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return repository_manager.get_local_git_remote_name(repository_name, remote_prefix="azuredevops") + + def get_azure_devops_repository(self, organization_name, project_name, repository_name): + repository_manager = RepositoryManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return repository_manager.get_azure_devops_repository(repository_name) + + def get_azure_devops_repo_url(self, organization_name, project_name, repository_name): + """Get the azure devops repository url""" + repository_manager = RepositoryManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return repository_manager.get_azure_devops_repo_url(repository_name) + + def setup_local_git_repository(self, organization_name, project_name, repository_name): + """Setup a repository locally and push to devops""" + repository_manager = RepositoryManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return repository_manager.setup_local_git_repository(repository_name, remote_prefix="azuredevops") + + def get_azure_devops_repository_branches(self, organization_name, project_name, repository_name): + """Get Azure Devops repository branches""" + repository_manager = RepositoryManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return repository_manager.get_azure_devops_repository_branches(repository_name) + + def push_local_to_azure_devops_repository(self, organization_name, project_name, repository_name, force=False): + """Push local context to Azure Devops repository""" + repository_manager = RepositoryManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return repository_manager.push_local_to_azure_devops_repository( + repository_name, + remote_prefix="azuredevops", + force=force + ) + + def list_pools(self, organization_name, project_name): + """List the devops pool resources""" + pool_manager = PoolManager(organization_name=organization_name, project_name=project_name, creds=self._creds) + return pool_manager.list_pools() + + def get_service_endpoints(self, organization_name, project_name, repository_name): + """Query a service endpoint detail""" + service_endpoint_manager = ServiceEndpointManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return service_endpoint_manager.get_service_endpoints(repository_name) + + def create_service_endpoint(self, organization_name, project_name, repository_name): + """Create a service endpoint to allow authentication via ARM service principal""" + service_endpoint_manager = ServiceEndpointManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return service_endpoint_manager.create_service_endpoint(repository_name) + + def list_service_endpoints(self, organization_name, project_name): + """List the different service endpoints in a project""" + service_endpoint_manager = ServiceEndpointManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return service_endpoint_manager.list_service_endpoints() + + def create_extension(self, organization_name, extension_name, publisher_name): + """Install an azure devops extension""" + extension_manager = ExtensionManager(organization_name=organization_name, creds=self._creds) + return extension_manager.create_extension(extension_name, publisher_name) + + def list_extensions(self, organization_name): + """List the azure devops extensions in an organization""" + extension_manager = ExtensionManager(organization_name=organization_name, creds=self._creds) + return extension_manager.list_extensions() + + def create_devops_build_definition(self, organization_name, project_name, repository_name, + build_definition_name, pool_name): + """Create a definition for an azure devops build""" + builder_manager = BuilderManager( + organization_name=organization_name, + project_name=project_name, + repository_name=repository_name, + creds=self._creds + ) + return builder_manager.create_devops_build_definition( + build_definition_name=build_definition_name, + pool_name=pool_name + ) + + def list_build_definitions(self, organization_name, project_name): + """List the azure devops build definitions within a project""" + builder_manager = BuilderManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return builder_manager.list_definitions() + + def create_build_object(self, organization_name, project_name, build_definition_name, pool_name): + """Create an azure devops build based off a previous definition""" + builder_manager = BuilderManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return builder_manager.create_build(build_definition_name, pool_name) + + def list_build_objects(self, organization_name, project_name): + """List already running and builds that have already run in an azure devops project""" + builder_manager = BuilderManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return builder_manager.list_builds() + + def get_build_logs_status(self, organization_name, project_name, build_id): + builder_manager = BuilderManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return builder_manager.get_build_logs_status(build_id) + + def get_build_logs_content_from_statuses(self, organization_name, project_name, build_id, prev_log, curr_log): + builder_manager = BuilderManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return builder_manager.get_build_logs_content_from_statuses(build_id, prev_log, curr_log) + + def list_artifacts(self, organization_name, project_name, build_id): + """List the azure devops artifacts from a build""" + artifact_manager = ArtifactManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return artifact_manager.list_artifacts(build_id) + + def create_release_definition(self, organization_name, project_name, build_name, artifact_name, + pool_name, service_endpoint_name, release_definition_name, app_type, + functionapp_name, storage_name, resource_name, settings): + """Create a release definition for azure devops that is for azure functions""" + release_manager = ReleaseManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return release_manager.create_release_definition(build_name, artifact_name, pool_name, + service_endpoint_name, release_definition_name, + app_type, functionapp_name, storage_name, + resource_name, settings=settings) + + def list_release_definitions(self, organization_name, project_name): + """List the release definitions for azure devops""" + release_manager = ReleaseManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return release_manager.list_release_definitions() + + def create_release(self, organization_name, project_name, release_definition_name): + """Create a release based off a previously defined release definition""" + release_manager = ReleaseManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return release_manager.create_release(release_definition_name) + + def get_latest_release(self, organization_name, project_name, release_defintion_name): + release_manager = ReleaseManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return release_manager.get_latest_release(release_defintion_name) + + def get_github_service_endpoints(self, organization_name, project_name, github_repository): + service_endpoint_manager = GithubServiceEndpointManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return service_endpoint_manager.get_github_service_endpoints(github_repository) + + def create_github_service_endpoint(self, organization_name, project_name, github_repository, github_pat): + service_endpoint_manager = GithubServiceEndpointManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return service_endpoint_manager.create_github_service_endpoint( + github_repository, + github_pat + ) + + def create_github_build_definition( + self, + organization_name, + project_name, + github_repository, + build_definition_name, + pool_name + ): + builder_manager = BuilderManager( + organization_name=organization_name, + project_name=project_name, + creds=self._creds + ) + return builder_manager.create_github_build_definition(build_definition_name, pool_name, github_repository) + + @staticmethod + def check_github_pat(github_pat): + github_user_manager = GithubUserManager() + return github_user_manager.check_github_pat(github_pat) + + @staticmethod + def check_github_repository(pat, repository_fullname): + github_repository_manager = GithubRepositoryManager(pat=pat) + return github_repository_manager.check_github_repository(repository_fullname) + + @staticmethod + def check_github_file(pat, repository_fullname, filepath): + github_repository_manager = GithubRepositoryManager(pat=pat) + return github_repository_manager.check_github_file(repository_fullname, filepath) + + @staticmethod + def get_github_content(pat, repository_fullname, filepath): + github_repository_manager = GithubRepositoryManager(pat=pat) + return github_repository_manager.get_content(repository_fullname, filepath, get_metadata=False) + + @staticmethod + def create_github_yaml(pat, language, app_type, repository_fullname, overwrite=False): + github_repository_manager = GithubYamlManager( + language=language, + app_type=app_type, + github_pat=pat, + github_repository=repository_fullname + ) + return github_repository_manager.create_yaml(overwrite) diff --git a/src/functionapp/azext_functionapp/commands.py b/src/functionapp/azext_functionapp/commands.py new file mode 100644 index 00000000000..7450c882a58 --- /dev/null +++ b/src/functionapp/azext_functionapp/commands.py @@ -0,0 +1,11 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +# pylint: disable=line-too-long + + +def load_command_table(self, _): + + with self.command_group('functionapp devops-pipeline') as g: + g.custom_command('create', 'create_devops_pipeline') diff --git a/src/functionapp/azext_functionapp/custom.py b/src/functionapp/azext_functionapp/custom.py new file mode 100644 index 00000000000..726b60a8d8a --- /dev/null +++ b/src/functionapp/azext_functionapp/custom.py @@ -0,0 +1,27 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.log import get_logger + +logger = get_logger(__name__) + + +def create_devops_pipeline( + cmd, + functionapp_name=None, + organization_name=None, + project_name=None, + repository_name=None, + overwrite_yaml=None, + allow_force_push=None, + github_pat=None, + github_repository=None +): + from .azure_devops_build_interactive import AzureDevopsBuildInteractive + azure_devops_build_interactive = AzureDevopsBuildInteractive(cmd, logger, functionapp_name, + organization_name, project_name, repository_name, + overwrite_yaml, allow_force_push, + github_pat, github_repository) + return azure_devops_build_interactive.interactive_azure_devops_build() diff --git a/src/functionapp/azext_functionapp/tests/__init__.py b/src/functionapp/azext_functionapp/tests/__init__.py new file mode 100644 index 00000000000..eaff94925e3 --- /dev/null +++ b/src/functionapp/azext_functionapp/tests/__init__.py @@ -0,0 +1,5 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- \ No newline at end of file diff --git a/src/functionapp/azext_functionapp/tests/latest/__init__.py b/src/functionapp/azext_functionapp/tests/latest/__init__.py new file mode 100644 index 00000000000..eaff94925e3 --- /dev/null +++ b/src/functionapp/azext_functionapp/tests/latest/__init__.py @@ -0,0 +1,5 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- \ No newline at end of file diff --git a/src/functionapp/azext_functionapp/tests/latest/test_devops_build_commands.py b/src/functionapp/azext_functionapp/tests/latest/test_devops_build_commands.py new file mode 100644 index 00000000000..d968cc603cb --- /dev/null +++ b/src/functionapp/azext_functionapp/tests/latest/test_devops_build_commands.py @@ -0,0 +1,179 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +import uuid +import os + +from knack.util import CLIError +from azure_functions_devops_build.exceptions import RoleAssignmentException +from azure.cli.testsdk import LiveScenarioTest, ResourceGroupPreparer, StorageAccountPreparer, JMESPathCheck + +CURR_DIR = os.getcwd() +TEST_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), 'sample_dotnet_function')) + + +class DevopsBuildCommandsTest(LiveScenarioTest): + def setUp(self): + super().setUp() + # You must be the organization owner and the subscription owner to run the following tests + # 1. Install devops extension 'az extension add --name azure-devops' + # 2. Login with 'az login' + # 3. Go to dev.azure.com apply for a personal access token, and login with 'az devops login' + # 4. Change the self.azure_devops_organization to your Azure DevOps organization + self.azure_devops_organization = "azureclitest" # Put "" to record live tests. Please change back to "azureclitest" as we have a routine live tests pipeline using this account. + self.os_type = "Windows" + self.runtime = "dotnet" + + self.functionapp = self.create_random_name(prefix='test-functionapp-', length=24) + self.azure_devops_project = self.create_random_name(prefix='test-project-', length=24) + self.azure_devops_repository = self.create_random_name(prefix='test-repository-', length=24) + + self.kwargs.update({ + 'ot': self.os_type, + 'rt': self.runtime, + 'org': self.azure_devops_organization, + 'proj': self.azure_devops_project, + 'repo': self.azure_devops_repository, + 'fn': self.functionapp, + }) + + @unittest.skip("test has been failing continuously and functions team needs to fix this.") + @ResourceGroupPreparer(random_name_length=24) + @StorageAccountPreparer(parameter_name='storage_account_for_test') + def test_devops_build_command(self, resource_group, resource_group_location, storage_account_for_test): + self._setUpDevopsEnvironment(resource_group, resource_group_location, storage_account_for_test) + + # Test devops build command + try: + result = self.cmd('functionapp devops-pipeline create --organization-name {org} --project-name {proj}' + ' --repository-name {repo} --functionapp-name {fn} --allow-force-push true' + ' --overwrite-yaml true').get_output_in_json() + + self.assertEqual(result['functionapp_name'], self.functionapp) + self.assertEqual(result['organization_name'], self.azure_devops_organization) + self.assertEqual(result['project_name'], self.azure_devops_project) + self.assertEqual(result['repository_name'], self.azure_devops_repository) + except CLIError: + raise unittest.SkipTest('You must be the owner of the subscription') + finally: + self._tearDownDevopsEnvironment() + + @unittest.skip("test has been failing continuously and functions team needs to fix this.") + @ResourceGroupPreparer(random_name_length=24) + @StorageAccountPreparer(parameter_name='storage_account_for_test') + def test_devops_build_mismatch_runtime(self, resource_group, resource_group_location, storage_account_for_test): + # Overwrite function runtime to use node + self.kwargs.update({'rt': 'node'}) + self._setUpDevopsEnvironment(resource_group, resource_group_location, storage_account_for_test) + + # Test devops build command (mismatched local_runtime:dotnet vs remote_runtime:node) + try: + self.cmd('functionapp devops-pipeline create --organization-name {org} --project-name {proj} ' + '--repository-name {repo} --functionapp-name {fn} --allow-force-push true ' + '--overwrite-yaml true', expect_failure=True) + finally: + self._tearDownDevopsEnvironment() + + @unittest.skip("test has been failing continuously and functions team needs to fix this.") + @ResourceGroupPreparer(random_name_length=24) + @StorageAccountPreparer(parameter_name='storage_account_for_test') + def test_devops_build_mismatch_functionapp(self, resource_group, resource_group_location, storage_account_for_test): + self._setUpDevopsEnvironment(resource_group, resource_group_location, storage_account_for_test) + + # Overwrite functionapp name to use a mismatched value + self.kwargs.update({'fn': self.create_random_name(prefix='mismatch', length=24)}) + + try: + self.cmd('functionapp devops-pipeline create --organization-name {org} --project-name {proj} ' + '--repository-name {repo} --functionapp-name {fn} --allow-force-push true ' + '--overwrite-yaml true', expect_failure=True) + finally: + self.kwargs.update({'fn': self.functionapp}) + self._tearDownDevopsEnvironment() + + @unittest.skip("test has been failing continuously and functions team needs to fix this.") + @ResourceGroupPreparer(random_name_length=24) + @StorageAccountPreparer(parameter_name='storage_account_for_test') + def test_devops_build_mismatch_organization(self, resource_group, resource_group_location, storage_account_for_test): + self._setUpDevopsEnvironment(resource_group, resource_group_location, storage_account_for_test) + + # Overwrite organization name to use a mismatched value + self.kwargs.update({'org': self.create_random_name(prefix='mismatch', length=24)}) + + try: + self.cmd('functionapp devops-pipeline create --organization-name {org} --project-name {proj} ' + '--repository-name {repo} --functionapp-name {fn} --allow-force-push true ' + '--overwrite-yaml true', expect_failure=True) + finally: + self.kwargs.update({'org': self.azure_devops_organization}) + self._tearDownDevopsEnvironment() + + @unittest.skip("test has been failing continuously and functions team needs to fix this.") + @ResourceGroupPreparer(random_name_length=24) + @StorageAccountPreparer(parameter_name='storage_account_for_test') + def test_devops_build_mismatch_project(self, resource_group, resource_group_location, storage_account_for_test): + self._setUpDevopsEnvironment(resource_group, resource_group_location, storage_account_for_test) + + # Overwrite project name to use a mismatched value + self.kwargs.update({'proj': self.create_random_name(prefix='mismatch', length=24)}) + + try: + self.cmd('functionapp devops-pipeline create --organization-name {org} --project-name {proj} ' + '--repository-name {repo} --functionapp-name {fn} --allow-force-push true ' + '--overwrite-yaml true', expect_failure=True) + finally: + self.kwargs.update({'proj': self.azure_devops_project}) + self._tearDownDevopsEnvironment() + + # Devops environment utilities + def _setUpDevopsEnvironment(self, resource_group, resource_group_location, storage_account_for_test): + self.kwargs.update({ + 'rg': resource_group, + 'cpl': resource_group_location, + 'sa': storage_account_for_test, + }) + + # Create a new functionapp + self.cmd('functionapp create --resource-group {rg} --storage-account {sa} ' + '--os-type {ot} --runtime {rt} --name {fn} --consumption-plan-location {cpl}', + checks=[JMESPathCheck('name', self.functionapp), JMESPathCheck('resourceGroup', resource_group)] + ) + + # Install azure devops extension + self.cmd('extension add --name azure-devops') + + # Create a new project in Azure Devops + result = self.cmd('devops project create --organization https://dev.azure.com/{org} --name {proj}', checks=[ + JMESPathCheck('name', self.azure_devops_project), + ]).get_output_in_json() + self.azure_devops_project_id = result['id'] + + # Create a new repository in Azure Devops + self.cmd('repos create --organization https://dev.azure.com/{org} --project {proj} --name {repo}', checks=[ + JMESPathCheck('name', self.azure_devops_repository), + ]) + + # Change directory to sample functionapp + os.chdir(TEST_DIR) + + def _tearDownDevopsEnvironment(self): + import time + # Change directory back + os.chdir(CURR_DIR) + + # Remove Azure Devops project + retry = 5 + for i in range(retry): + try: + self.cmd('devops project delete --organization https://dev.azure.com/{org} --id {id} --yes'.format( + org=self.azure_devops_organization, + id=self.azure_devops_project_id + )) + break + except Exception as ex: + if i == retry - 1: + raise ex + time.sleep(120) diff --git a/src/functionapp/azext_functionapp/tests/latest/test_devops_build_commands_thru_mock.py b/src/functionapp/azext_functionapp/tests/latest/test_devops_build_commands_thru_mock.py new file mode 100644 index 00000000000..38155f3144a --- /dev/null +++ b/src/functionapp/azext_functionapp/tests/latest/test_devops_build_commands_thru_mock.py @@ -0,0 +1,211 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +from unittest.mock import MagicMock, patch +from knack.util import CLIError +from azure.cli.core.mock import DummyCli +from ...azure_devops_build_interactive import ( + AzureDevopsBuildInteractive +) + + +def interactive_patch_path(interactive_module): + return "azext_functionapp.azure_devops_build_interactive.{}".format(interactive_module) + + +class TestDevopsBuildCommandsMocked(unittest.TestCase): + @patch("azext_functionapp.azure_devops_build_interactive.AzureDevopsBuildProvider", new=MagicMock()) + def setUp(self): + mock_logger = MagicMock() + mock_cmd = MagicMock() + mock_cmd.cli_ctx = DummyCli() + self._client = AzureDevopsBuildInteractive( + cmd=mock_cmd, + logger=mock_logger, + functionapp_name=None, + organization_name=None, + project_name=None, + repository_name=None, + overwrite_yaml=None, + allow_force_push=None, + github_pat=None, + github_repository=None + ) + + @patch(interactive_patch_path("prompt_choice_list")) + def test_check_scenario_prompt_choice_list(self, prompt_choice_list): + self._client.check_scenario() + prompt_choice_list.assert_called_once() + + def test_check_scenario_azure_devops(self): + self._client.repository_name = "azure_devops_repository" + self._client.check_scenario() + result = self._client.scenario + self.assertEqual(result, "AZURE_DEVOPS") + + def test_check_scenario_github_pat(self): + self._client.github_pat = "github_pat" + self._client.check_scenario() + result = self._client.scenario + self.assertEqual(result, "GITHUB_INTEGRATION") + + def test_check_scenario_github_repository(self): + self._client.github_repository = "github_repository" + self._client.check_scenario() + result = self._client.scenario + self.assertEqual(result, "GITHUB_INTEGRATION") + + @patch(interactive_patch_path("AzureDevopsBuildProvider.check_git")) + def test_azure_devops_prechecks_no_git(self, check_git): + check_git.return_value = False + with self.assertRaises(CLIError): + self._client.pre_checks_azure_devops() + + @patch(interactive_patch_path("AzureDevopsBuildProvider.check_git")) + @patch(interactive_patch_path("os.path.exists")) + def test_azure_devops_prechecks_no_file(self, exists, check_git): + check_git.return_value = True + exists.return_value = False + with self.assertRaises(CLIError): + self._client.pre_checks_azure_devops() + + @patch(interactive_patch_path("AzureDevopsBuildProvider.check_git")) + @patch(interactive_patch_path("os.path.exists")) + @patch(interactive_patch_path("AzureDevopsBuildInteractive._find_local_repository_runtime_language")) + def test_azure_devops_prechecks_no_file_with_runtime_language( + self, + _find_local_repository_runtime_language, + exists, + check_git + ): + check_git.return_value = True + exists.return_value = True + _find_local_repository_runtime_language.return_value = "node" + self._client.functionapp_language = "dotnet" + with self.assertRaises(CLIError): + self._client.pre_checks_azure_devops() + + @patch(interactive_patch_path("AzureDevopsBuildProvider.check_git")) + @patch(interactive_patch_path("os.path.exists")) + @patch(interactive_patch_path("AzureDevopsBuildInteractive._find_local_repository_runtime_language")) + def test_azure_devops_prechecks(self, _find_local_repository_runtime_language, exists, check_git): + check_git.return_value = True + exists.return_value = True + _find_local_repository_runtime_language.return_value = "node" + self._client.functionapp_language = "node" + self._client.pre_checks_azure_devops() + + @patch(interactive_patch_path("AzureDevopsBuildProvider.check_github_file")) + def test_github_prechecks_no_file(self, check_github_file): + check_github_file.return_value = False + with self.assertRaises(CLIError): + self._client.pre_checks_github() + + @patch(interactive_patch_path("AzureDevopsBuildProvider.check_github_file")) + @patch(interactive_patch_path("AzureDevopsBuildInteractive._find_github_repository_runtime_language")) + def test_github_prechecks_wrong_runtime(self, _find_github_repository_runtime_language, check_github_file): + check_github_file.return_value = True + _find_github_repository_runtime_language.return_value = "node" + self._client.functionapp_language = "dotnet" + with self.assertRaises(CLIError): + self._client.pre_checks_github() + + @patch(interactive_patch_path("AzureDevopsBuildProvider.check_github_file")) + @patch(interactive_patch_path("AzureDevopsBuildInteractive._find_github_repository_runtime_language")) + def test_github_prechecks_no_runtime(self, _find_github_repository_runtime_language, check_github_file): + check_github_file.return_value = True + _find_github_repository_runtime_language.return_value = None + self._client.functionapp_language = "dotnet" + self._client.pre_checks_github() + + @patch(interactive_patch_path("AzureDevopsBuildProvider.check_github_file")) + @patch(interactive_patch_path("AzureDevopsBuildInteractive._find_github_repository_runtime_language")) + def test_github_prechecks(self, _find_github_repository_runtime_language, check_github_file): + check_github_file.return_value = True + _find_github_repository_runtime_language.return_value = "dotnet" + self._client.functionapp_language = "dotnet" + self._client.pre_checks_github() + + @patch(interactive_patch_path("get_app_settings"), MagicMock()) + @patch(interactive_patch_path("show_functionapp"), MagicMock()) + @patch(interactive_patch_path("AzureDevopsBuildInteractive._find_type"), MagicMock()) + @patch(interactive_patch_path("AzureDevopsBuildInteractive._get_functionapp_runtime_language"), MagicMock()) + @patch(interactive_patch_path("AzureDevopsBuildInteractive._get_functionapp_storage_name"), MagicMock()) + @patch(interactive_patch_path("AzureDevopsBuildInteractive._select_functionapp")) + def test_process_functionapp_prompt_choice_list(self, _select_functionapp): + self._client.process_functionapp() + _select_functionapp.assert_called_once() + + @patch(interactive_patch_path("AzureDevopsBuildInteractive._create_organization")) + @patch(interactive_patch_path("AzureDevopsBuildInteractive._select_organization")) + @patch(interactive_patch_path("prompt_y_n")) + def test_process_organization_prompt_choice_list(self, prompt_y_n, _select_organization, _create_organization): + # Choose to select existing organization + prompt_y_n.return_value = True + self._client.process_organization() + _select_organization.assert_called_once() + + # Choose to create a new organization + prompt_y_n.return_value = False + self._client.process_organization() + _create_organization.assert_called_once() + + @patch(interactive_patch_path("AzureDevopsBuildInteractive._create_project")) + @patch(interactive_patch_path("AzureDevopsBuildInteractive._select_project")) + @patch(interactive_patch_path("prompt_y_n")) + def test_process_project(self, prompt_y_n, _select_project, _create_project): + # Choose to select existing project + prompt_y_n.return_value = True + self._client.process_project() + _select_project.assert_called_once() + + # Choose to create a new project + prompt_y_n.return_value = False + self._client.process_project() + _create_project.assert_called_once() + + @patch(interactive_patch_path("AzureDevopsBuildProvider.create_github_yaml")) + @patch(interactive_patch_path("AzureDevopsBuildProvider.check_github_file")) + @patch(interactive_patch_path("prompt_y_n")) + def test_process_yaml_github_no_yaml(self, prompt_y_n, check_github_file, create_github_yaml): + check_github_file.return_value = False + self._client.process_yaml_github() + prompt_y_n.assert_not_called() + create_github_yaml.assert_called_once() + + @patch(interactive_patch_path("AzureDevopsBuildProvider.create_github_yaml")) + @patch(interactive_patch_path("AzureDevopsBuildProvider.check_github_file")) + @patch(interactive_patch_path("prompt_y_n")) + def test_process_yaml_github_existing_no_consent(self, prompt_y_n, check_github_file, create_github_yaml): + check_github_file.return_value = True + prompt_y_n.return_value = False + self._client.process_yaml_github() + prompt_y_n.assert_called_once() + create_github_yaml.assert_not_called() + + @patch(interactive_patch_path("AzureDevopsBuildProvider.create_github_yaml")) + @patch(interactive_patch_path("AzureDevopsBuildProvider.check_github_file")) + @patch(interactive_patch_path("prompt_y_n")) + def test_process_yaml_github_existing_consent(self, prompt_y_n, check_github_file, create_github_yaml): + check_github_file.return_value = True + prompt_y_n.return_value = True + self._client.process_yaml_github() + prompt_y_n.assert_called_once() + create_github_yaml.assert_called_once() + + @patch(interactive_patch_path("AzureDevopsBuildProvider.create_github_yaml")) + @patch(interactive_patch_path("AzureDevopsBuildProvider.check_github_file")) + @patch(interactive_patch_path("prompt_y_n")) + def test_process_yaml_github_existing_overwrite(self, prompt_y_n, check_github_file, create_github_yaml): + self._client.overwrite_yaml = True + check_github_file.return_value = True + self._client.process_yaml_github() + prompt_y_n.assert_not_called() + create_github_yaml.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/src/functionapp/setup.cfg b/src/functionapp/setup.cfg new file mode 100644 index 00000000000..f9aa3181f05 --- /dev/null +++ b/src/functionapp/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 \ No newline at end of file diff --git a/src/functionapp/setup.py b/src/functionapp/setup.py new file mode 100644 index 00000000000..a24a72a31f6 --- /dev/null +++ b/src/functionapp/setup.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +from codecs import open +from setuptools import setup, find_packages +try: + from azure_bdist_wheel import cmdclass +except ImportError: + from distutils import log as logger + logger.warn("Wheel is not available, disabling bdist_wheel hook") + +# TODO: Confirm this is the right version number you want and it matches your +# HISTORY.rst entry. +VERSION = '0.1.0' + +# The full list of classifiers is available at +# https://pypi.python.org/pypi?%3Aaction=list_classifiers +CLASSIFIERS = [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'License :: OSI Approved :: MIT License', +] + +# TODO: Add any additional SDK dependencies here +DEPENDENCIES = ["azure-functions-devops-build~=0.0.22"] + +with open('README.rst', 'r', encoding='utf-8') as f: + README = f.read() +with open('HISTORY.rst', 'r', encoding='utf-8') as f: + HISTORY = f.read() + +setup( + name='functionapp', + version=VERSION, + description='Additional commands for Azure Functions.', + long_description='Support for managing Azure Functions resources and configs.', + author='Graham Zuber', + author_email='grzuber@microsoft.com', + url='https://github.com/Azure/azure-cli-extensions/tree/master/src/functionapp', + license='MIT', + classifiers=CLASSIFIERS, + packages=find_packages(exclude=["tests"]), + install_requires=DEPENDENCIES, + package_data={'azext_functionapp': ['azext_metadata.json']}, +) diff --git a/src/service_name.json b/src/service_name.json index 96caa7cab44..4b8ec0e33a6 100644 --- a/src/service_name.json +++ b/src/service_name.json @@ -513,6 +513,11 @@ "Command": "az purview", "AzureServiceName": "Azure Purview", "URL": "https://docs.microsoft.com/en-us/azure/purview/" + }, + { + "Command": "az functionapp", + "AzureServiceName": "Azure Functions", + "URL": "https://docs.microsoft.com/en-us/azure/azure-functions/" } ]