From 2ad25b0f93ae937ff3fc2b5f1d5c1ecb2a11c3db Mon Sep 17 00:00:00 2001 From: Haroon Feisal <38823870+haroonf@users.noreply.github.com> Date: Wed, 27 Jul 2022 20:40:11 -0700 Subject: [PATCH] [App Service] `functionapp deployment github-actions`: Add new functionapp github-actions commands (#23326) --- .../command_modules/appservice/_constants.py | 17 + .../appservice/_github_oauth.py | 65 ++- .../cli/command_modules/appservice/_help.py | 29 ++ .../cli/command_modules/appservice/_params.py | 15 + .../command_modules/appservice/_validators.py | 32 +- .../command_modules/appservice/commands.py | 9 +- .../cli/command_modules/appservice/custom.py | 433 +++++++++++++++++- .../cli/command_modules/appservice/utils.py | 39 ++ 8 files changed, 625 insertions(+), 14 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_constants.py b/src/azure-cli/azure/cli/command_modules/appservice/_constants.py index 424cd3a69ea..7d584953637 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_constants.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_constants.py @@ -30,6 +30,8 @@ "repo", "workflow" ] +LOGICAPP_KIND = "workflowapp" +FUNCTIONAPP_KIND = "functionapp" class FUNCTIONS_STACKS_API_KEYS(): @@ -53,6 +55,7 @@ def __init__(self): self.SUPPORTED_EXTENSION_VERSIONS = 'supportedFunctionsExtensionVersions' self.USE_32_BIT_WORKER_PROC = 'use32BitWorkerProcess' self.FUNCTIONS_WORKER_RUNTIME = 'FUNCTIONS_WORKER_RUNTIME' + self.GIT_HUB_ACTION_SETTINGS = 'git_hub_action_settings' GENERATE_RANDOM_APP_NAMES = os.path.abspath(os.path.join(os.path.abspath(__file__), @@ -75,3 +78,17 @@ def __init__(self): 'java': 'AppService/windows/java-jar-webapp-on-azure.yml', 'tomcat': 'AppService/windows/java-war-webapp-on-azure.yml' } + +LINUX_FUNCTIONAPP_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH = { + 'node': 'FunctionApp/linux-node.js-functionapp-on-azure.yml', + 'python': 'FunctionApp/linux-python-functionapp-on-azure.yml', + 'dotnet': 'FunctionApp/linux-dotnet-functionapp-on-azure.yml', + 'java': 'FunctionApp/linux-java-functionapp-on-azure.yml', +} + +WINDOWS_FUNCTIONAPP_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH = { + 'node': 'FunctionApp/windows-node.js-functionapp-on-azure.yml', + 'dotnet': 'FunctionApp/windows-dotnet-functionapp-on-azure.yml', + 'java': 'FunctionApp/windows-java-functionapp-on-azure.yml', + 'powershell': 'FunctionApp/windows-powershell-functionapp-on-azure.yml', +} diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_github_oauth.py b/src/azure-cli/azure/cli/command_modules/appservice/_github_oauth.py index 5f18c69e349..c43f257f3fa 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_github_oauth.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_github_oauth.py @@ -2,11 +2,18 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +# pylint: disable=consider-using-f-string + +import os +import sys +from datetime import datetime -from azure.cli.core.azclierror import (ValidationError, CLIInternalError, UnclassifiedUserFault) from knack.log import get_logger +from azure.cli.core.util import open_page_in_browser +from azure.cli.core.auth.persistence import SecretStore, build_persistence +from azure.cli.core.azclierror import (ValidationError, CLIInternalError, UnclassifiedUserFault) -from ._constants import (GITHUB_OAUTH_CLIENT_ID, GITHUB_OAUTH_SCOPES) +from .utils import repo_url_to_name logger = get_logger(__name__) @@ -17,7 +24,56 @@ ''' -def get_github_access_token(cmd, scope_list=None): # pylint: disable=unused-argument +GITHUB_OAUTH_CLIENT_ID = "8d8e1f6000648c575489" +GITHUB_OAUTH_SCOPES = [ + "admin:repo_hook", + "repo", + "workflow" +] + + +def _get_github_token_secret_store(cmd): + location = os.path.join(cmd.cli_ctx.config.config_dir, "github_token_cache") + # TODO use core CLI util to take care of this once it's merged and released + encrypt = sys.platform.startswith('win32') # encryption not supported on non-windows platforms + file_persistence = build_persistence(location, encrypt) + return SecretStore(file_persistence) + + +def cache_github_token(cmd, token, repo): + repo = repo_url_to_name(repo) + secret_store = _get_github_token_secret_store(cmd) + cache = secret_store.load() + + for entry in cache: + if isinstance(entry, dict) and entry.get("value") == token: + if repo not in entry.get("repos", []): + entry["repos"] = [*entry.get("repos", []), repo] + entry["last_modified_timestamp"] = datetime.utcnow().timestamp() + break + else: + cache_entry = {"last_modified_timestamp": datetime.utcnow().timestamp(), "value": token, "repos": [repo]} + cache = [cache_entry, *cache] + + secret_store.save(cache) + + +def load_github_token_from_cache(cmd, repo): + repo = repo_url_to_name(repo) + secret_store = _get_github_token_secret_store(cmd) + cache = secret_store.load() + + if isinstance(cache, list): + for entry in cache: + if isinstance(entry, dict) and repo in entry.get("repos", []): + return entry.get("value") + + return None + + +def get_github_access_token(cmd, scope_list=None, token=None): # pylint: disable=unused-argument + if token: + return token if scope_list: for scope in scope_list: if scope not in GITHUB_OAUTH_SCOPES: @@ -45,6 +101,7 @@ def get_github_access_token(cmd, scope_list=None): # pylint: disable=unused-arg expires_in_seconds = int(parsed_response['expires_in'][0]) logger.warning('Please navigate to %s and enter the user code %s to activate and ' 'retrieve your github personal access token', verification_uri, user_code) + open_page_in_browser("https://github.com/login/device") timeout = time.time() + expires_in_seconds logger.warning("Waiting up to '%s' minutes for activation", str(expires_in_seconds // 60)) @@ -76,6 +133,6 @@ def get_github_access_token(cmd, scope_list=None): # pylint: disable=unused-arg return parsed_confirmation_response['access_token'][0] except Exception as e: raise CLIInternalError( - 'Error: {}. Please try again, or retrieve personal access token from the Github website'.format(e)) + 'Error: {}. Please try again, or retrieve personal access token from the Github website'.format(e)) from e raise UnclassifiedUserFault('Activation did not happen in time. Please try again') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index d4bf390a52c..9fa83dbeb05 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -605,6 +605,35 @@ az functionapp deployment user set --user-name MyUserName """ +helps['functionapp deployment github-actions'] = """ +type: group +short-summary: Configure GitHub Actions for a functionapp +""" + +helps['functionapp deployment github-actions add'] = """ +type: command +short-summary: Add a GitHub Actions workflow file to the specified repository. The workflow will build and deploy your app to the specified functionapp. +examples: + - name: Add GitHub Actions to a specified repository, providing personal access token + text: > + az functionapp deployment github-actions add --repo "githubUser/githubRepo" -g MyResourceGroup -n MyFunctionapp --token MyPersonalAccessToken + - name: Add GitHub Actions to a specified repository, using interactive method of retrieving personal access token + text: > + az functionapp deployment github-actions add --repo "githubUser/githubRepo" -g MyResourceGroup -n MyFunctionapp --login-with-github +""" + +helps['functionapp deployment github-actions remove'] = """ +type: command +short-summary: Remove and disconnect the GitHub Actions workflow file from the specified repository. +examples: + - name: Remove GitHub Actions from a specified repository, providing personal access token + text: > + az functionapp deployment github-actions remove --repo "githubUser/githubRepo" -g MyResourceGroup -n MyFunctionapp --token MyPersonalAccessToken + - name: Remove GitHub Actions from a specified repository, using interactive method of retrieving personal access token + text: > + az functionapp deployment github-actions remove --repo "githubUser/githubRepo" -g MyResourceGroup -n MyFunctionapp --login-with-github +""" + helps['functionapp function'] = """ type: group short-summary: Manage function app functions. diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index 423cbd6d901..0c7289d650d 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -824,6 +824,21 @@ def load_arguments(self, _): help="swap types. use 'preview' to apply target slot's settings on the source slot first; use 'swap' to complete it; use 'reset' to reset the swap", arg_type=get_enum_type(['swap', 'preview', 'reset'])) + with self.argument_context('functionapp deployment github-actions')as c: + c.argument('name', arg_type=functionapp_name_arg_type) + c.argument('resource_group', arg_type=resource_group_name_type) + c.argument('repo', help='The GitHub repository to which the workflow file will be added. In the format: https://github.com// or /') + c.argument('token', help='A Personal Access Token with write access to the specified repository. For more information: https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line', arg_group="Github") + c.argument('slot', options_list=['--slot', '-s'], help='The name of the slot. Default to the production slot if not specified.') + c.argument('branch', options_list=['--branch', '-b'], help='The branch to which the workflow file will be added.') + c.argument('login_with_github', help="Interactively log in with Github to retrieve the Personal Access Token", arg_group="Github") + + with self.argument_context('functionapp deployment github-actions add')as c: + c.argument('runtime', options_list=['--runtime', '-r'], help='The functions runtime stack. Use "az functionapp list-runtimes" to check supported runtimes and versions.') + c.argument('runtime_version', options_list=['--runtime-version', '-v'], help='The version of the functions runtime stack. The functions runtime stack. Use "az functionapp list-runtimes" to check supported runtimes and versions.') + c.argument('force', options_list=['--force', '-f'], help='When true, the command will overwrite any workflow file with a conflicting name.', action='store_true') + c.argument('build_path', help='Path to the build requirements. Ex: package path, POM XML directory.') + with self.argument_context('functionapp keys', id_part=None) as c: c.argument('resource_group_name', arg_type=resource_group_name_type,) c.argument('name', arg_type=functionapp_name_arg_type, diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_validators.py b/src/azure-cli/azure/cli/command_modules/appservice/_validators.py index 6eb269fb8bd..53e228b6c80 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_validators.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_validators.py @@ -18,7 +18,7 @@ from ._appservice_utils import _generic_site_operation from ._client_factory import web_client_factory from .utils import (_normalize_sku, get_sku_tier, _normalize_location, get_resource_name_and_group, - get_resource_if_exists) + get_resource_if_exists, is_functionapp, is_logicapp, is_webapp) logger = get_logger(__name__) @@ -390,3 +390,33 @@ def validate_webapp_up(cmd, namespace): ase = client.app_service_environments.get(resource_group_name=ase_rg, name=ase_name) _validate_ase_is_v3(ase) _validate_ase_not_ilb(ase) + + +def _get_app_name(namespace): + if hasattr(namespace, "name"): + return namespace.name + if hasattr(namespace, "webapp"): + return namespace.webapp + return None + + +def validate_app_is_webapp(cmd, namespace): + client = web_client_factory(cmd.cli_ctx) + name = _get_app_name(namespace) + rg = namespace.resource_group + app = get_resource_if_exists(client.web_apps, name=name, resource_group_name=rg) + if is_functionapp(app): + raise ValidationError(f"App '{name}' in group '{rg}' is a function app.") + if is_logicapp(app): + raise ValidationError(f"App '{name}' in group '{rg}' is a logic app.") + + +def validate_app_is_functionapp(cmd, namespace): + client = web_client_factory(cmd.cli_ctx) + name = _get_app_name(namespace) + rg = namespace.resource_group + app = get_resource_if_exists(client.web_apps, name=name, resource_group_name=rg) + if is_logicapp(app): + raise ValidationError(f"App '{name}' in group '{rg}' is a logic app.") + if is_webapp(app): + raise ValidationError(f"App '{name}' in group '{rg}' is a web app.") diff --git a/src/azure-cli/azure/cli/command_modules/appservice/commands.py b/src/azure-cli/azure/cli/command_modules/appservice/commands.py index 038040ef33e..da1c1963490 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -10,7 +10,8 @@ from ._client_factory import cf_web_client, cf_plans, cf_webapps from ._validators import (validate_onedeploy_params, validate_staticsite_link_function, validate_staticsite_sku, validate_vnet_integration, validate_asp_create, validate_functionapp_asp_create, - validate_webapp_up, validate_app_exists, validate_add_vnet) + validate_webapp_up, validate_app_exists, validate_add_vnet, validate_app_is_functionapp, + validate_app_is_webapp) def output_slots_in_table(slots): @@ -255,10 +256,14 @@ def load_command_table(self, _): g.custom_command('config', 'enable_cd') g.custom_command('show-cd-url', 'show_container_cd_url') - with self.command_group('webapp deployment github-actions') as g: + with self.command_group('webapp deployment github-actions', validator=validate_app_is_webapp) as g: g.custom_command('add', 'add_github_actions') g.custom_command('remove', 'remove_github_actions') + with self.command_group('functionapp deployment github-actions', validator=validate_app_is_functionapp) as g: + g.custom_command('add', 'add_functionapp_github_actions') + g.custom_command('remove', 'remove_functionapp_github_actions') + with self.command_group('webapp auth') as g: g.custom_show_command('show', 'get_auth_settings') g.custom_command('update', 'update_auth_settings') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 328bdaf5f23..986e0fad4b9 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -47,7 +47,7 @@ from azure.cli.core.azclierror import (InvalidArgumentValueError, MutuallyExclusiveArgumentError, ResourceNotFoundError, RequiredArgumentMissingError, ValidationError, CLIInternalError, UnclassifiedUserFault, AzureResponseError, AzureInternalError, - ArgumentUsageError) + ArgumentUsageError, FileOperationError) from .tunnel import TunnelServer @@ -64,7 +64,7 @@ _get_location_from_webapp, _normalize_location, get_pool_manager, use_additional_properties, get_app_service_plan_from_webapp, - get_resource_if_exists) + get_resource_if_exists, repo_url_to_name, get_token) from ._create_util import (zip_contents_from_dir, get_runtime_version_details, create_resource_group, get_app_details, check_resource_group_exists, set_location, get_site_availability, get_profile_username, get_plan_to_use, get_lang_from_content, get_rg_to_use, get_sku_to_use, @@ -73,8 +73,9 @@ FUNCTIONS_WINDOWS_RUNTIME_VERSION_REGEX, FUNCTIONS_NO_V2_REGIONS, PUBLIC_CLOUD, LINUX_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH, WINDOWS_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH, DOTNET_RUNTIME_NAME, NETCORE_RUNTIME_NAME, ASPDOTNET_RUNTIME_NAME, LINUX_OS_NAME, - WINDOWS_OS_NAME) -from ._github_oauth import (get_github_access_token) + WINDOWS_OS_NAME, LINUX_FUNCTIONAPP_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH, + WINDOWS_FUNCTIONAPP_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH) +from ._github_oauth import (get_github_access_token, cache_github_token) from ._validators import validate_and_convert_to_int, validate_range_of_int_flag logger = get_logger(__name__) @@ -3195,7 +3196,8 @@ class _FunctionAppStackRuntimeHelper(_AbstractStackRuntimeHelper): # pylint: disable=too-few-public-methods,too-many-instance-attributes class Runtime: def __init__(self, name=None, version=None, is_preview=False, supported_func_versions=None, linux=False, - app_settings_dict=None, site_config_dict=None, app_insights=False, default=False): + app_settings_dict=None, site_config_dict=None, app_insights=False, default=False, + github_actions_properties=None): self.name = name self.version = version self.is_preview = is_preview @@ -3205,6 +3207,7 @@ def __init__(self, name=None, version=None, is_preview=False, supported_func_ver self.site_config_dict = dict() if not site_config_dict else site_config_dict self.app_insights = app_insights self.default = default + self.github_actions_properties = github_actions_properties self.display_name = "{}|{}".format(name, version) if version else name @@ -3219,7 +3222,7 @@ def __init__(self, cmd, linux=False, windows=False): self.KEYS = FUNCTIONS_STACKS_API_KEYS() super().__init__(cmd, linux=linux, windows=windows) - def resolve(self, runtime, version=None, functions_version=None, linux=False): + def resolve(self, runtime, version=None, functions_version=None, linux=False, disable_version_error=False): stacks = self.stacks runtimes = [r for r in stacks if r.linux == linux and runtime == r.name] os = LINUX_OS_NAME if linux else WINDOWS_OS_NAME @@ -3235,12 +3238,17 @@ def resolve(self, runtime, version=None, functions_version=None, linux=False): # help convert previously acceptable versions into correct ones if match not found old_to_new_version = { "11": "11.0", - "8": "8.0" + "8": "8.0", + "7": "7.0", + "6.0": "6", + "1.8": "8.0" } new_version = old_to_new_version.get(version) matched_runtime_version = next((r for r in runtimes if r.version == new_version), None) if not matched_runtime_version: versions = [r.version for r in runtimes] + if disable_version_error: + return None raise ValidationError("Invalid version: {0} for runtime {1} and os {2}. Supported versions for runtime " "{1} and os {2} are: {3}. " "Run 'az functionapp list-runtimes' for more details on supported runtimes. " @@ -3306,6 +3314,7 @@ def _parse_minor_version(self, runtime_settings, major_version_name, minor_versi self.KEYS.APPLICATION_INSIGHTS: runtime_settings.app_insights_settings.is_supported, self.KEYS.SITE_CONFIG_DICT: runtime_settings.site_config_properties_dictionary, self.KEYS.IS_DEFAULT: bool(runtime_settings.is_default), + self.KEYS.GIT_HUB_ACTION_SETTINGS: runtime_settings.git_hub_action_settings } runtime_name = (runtime_settings.app_settings_dictionary.get(self.KEYS.FUNCTIONS_WORKER_RUNTIME) or @@ -3324,6 +3333,7 @@ def _create_runtime_from_properties(self, runtime_name, version_name, version_pr app_settings_dict=version_properties[self.KEYS.APP_SETTINGS_DICT], app_insights=version_properties[self.KEYS.APPLICATION_INSIGHTS], default=version_properties[self.KEYS.IS_DEFAULT], + github_actions_properties=version_properties[self.KEYS.GIT_HUB_ACTION_SETTINGS] ) def _parse_raw_stacks(self, stacks): @@ -5261,6 +5271,259 @@ def remove_github_actions(cmd, resource_group, name, repo, token=None, slot=None return "Disconnected successfully." +def add_functionapp_github_actions(cmd, resource_group, name, repo, runtime=None, runtime_version=None, token=None, # pylint: disable=too-many-statements,too-many-branches + slot=None, branch='master', build_path=".", login_with_github=False, force=False): + if login_with_github: + token = get_github_access_token(cmd, ["admin:repo_hook", "repo", "workflow"], token) + repo = repo_url_to_name(repo) + token = get_token(cmd, repo, token) + + # Verify resource group, app + site_availability = get_site_availability(cmd, name) + if site_availability.name_available or (not site_availability.name_available and + site_availability.reason == 'Invalid'): + raise ResourceNotFoundError( + "The Resource 'Microsoft.Web/sites/%s' under resource group '%s' " + "was not found." % (name, resource_group)) + app_details = get_app_details(cmd, name) + if app_details is None: + raise ResourceNotFoundError( + "Unable to retrieve details of the existing app %s. Please check that the app is a part of " + "the current subscription" % name) + current_rg = app_details.resource_group + if resource_group is not None and (resource_group.lower() != current_rg.lower()): + raise ResourceNotFoundError("The webapp %s exists in ResourceGroup %s and does not match the " + "value entered %s. Please re-run command with the correct " + "parameters." % (name, current_rg, resource_group)) + + app = show_app(cmd, resource_group, name, slot) + is_linux = app.reserved + + # Verify github repo + from github import Github, GithubException + from github.GithubException import BadCredentialsException, UnknownObjectException + + if repo.strip()[-1] == '/': + repo = repo.strip()[:-1] + + g = Github(token) + github_repo = None + try: + github_repo = g.get_repo(repo) + try: + github_repo.get_branch(branch=branch) + except GithubException as e: + error_msg = "Encountered GitHub error when accessing {} branch in {} repo.".format(branch, repo) + if e.data and e.data['message']: + error_msg += " Error: {}".format(e.data['message']) + raise ValidationError(error_msg) + logger.warning('Verified GitHub repo and branch') + except BadCredentialsException: + raise ValidationError("Could not authenticate to the repository. Please create a Personal Access Token and use " + "the --token argument. Run 'az functionapp deployment github-actions add --help' " + "for more information.") + except GithubException as e: + error_msg = "Encountered GitHub error when accessing {} repo".format(repo) + if e.data and e.data['message']: + error_msg += " Error: {}".format(e.data['message']) + raise ValidationError(error_msg) + + # Get runtime info + app_runtime_info = _get_functionapp_runtime_info( + cmd=cmd, resource_group=resource_group, name=name, slot=slot, is_linux=is_linux) + + app_runtime_string = app_runtime_info['app_runtime'] + github_actions_version = app_runtime_info['app_runtime_version'] + + if runtime: + if app_runtime_string and app_runtime_string.lower() != runtime.lower(): + logger.warning('The app runtime: %s does not match the runtime specified: ' + '%s. Using the specified runtime %s.', app_runtime_string, runtime, runtime) + app_runtime_string = runtime + + if runtime_version: + if github_actions_version and github_actions_version.lower() != runtime_version.lower(): + logger.warning('The app runtime version: %s does not match the runtime version specified: ' + '%s. Using the specified runtime %s.', github_actions_version, runtime_version, + runtime_version) + github_actions_version = runtime_version + + if not app_runtime_string and not github_actions_version: + raise ValidationError('Could not detect runtime or runtime version. Please specify' + 'using the --runtime and --runtime-version flags.') + if not app_runtime_string: + raise ValidationError('Could not detect runtime. Please specify using the --runtime flag.') + if not github_actions_version: + raise ValidationError('Could not detect runtime version. Please specify using the --runtime-version flag.') + + # Verify runtime + gh actions support + functionapp_version = app_runtime_info['functionapp_version'] + github_actions_version = _get_functionapp_runtime_version(cmd=cmd, runtime_string=app_runtime_string, + runtime_version=github_actions_version, + functionapp_version=functionapp_version, + is_linux=is_linux) + if not github_actions_version: + runtime_version = runtime_version if runtime_version else app_runtime_info['app_runtime_version'] + raise ValidationError("Runtime %s version %s is not supported for GitHub Actions deployments " + "on os %s." % (app_runtime_string, runtime_version, + "linux" if is_linux else "windows")) + + # Get workflow template + logger.warning('Getting workflow template using runtime: %s', app_runtime_string) + workflow_template = _get_functionapp_workflow_template(github=g, runtime_string=app_runtime_string, + is_linux=is_linux) + + # Fill workflow template + guid = str(uuid.uuid4()).replace('-', '') + publish_profile_name = "AZURE_FUNCTIONAPP_PUBLISH_PROFILE_{}".format(guid) + logger.warning( + 'Filling workflow template with name: %s, branch: %s, version: %s, slot: %s, build_path: %s', + name, branch, github_actions_version, slot if slot else 'production', build_path) + completed_workflow_file = _fill_functionapp_workflow_template(content=workflow_template.decoded_content.decode(), + name=name, build_path=build_path, + version=github_actions_version, + publish_profile=publish_profile_name) + completed_workflow_file = completed_workflow_file.encode() + + # Check if workflow exists in repo, otherwise push + if slot: + file_name = "{}_{}({}).yml".format(branch.replace('/', '-'), name.lower(), slot) + else: + file_name = "{}_{}.yml".format(branch.replace('/', '-'), name.lower()) + dir_path = "{}/{}".format('.github', 'workflows') + file_path = "{}/{}".format(dir_path, file_name) + try: + existing_workflow_file = github_repo.get_contents(path=file_path, ref=branch) + existing_publish_profile_name = _get_publish_profile_from_workflow_file( + workflow_file=str(existing_workflow_file.decoded_content)) + if existing_publish_profile_name: + completed_workflow_file = completed_workflow_file.decode() + completed_workflow_file = completed_workflow_file.replace( + publish_profile_name, existing_publish_profile_name) + completed_workflow_file = completed_workflow_file.encode() + publish_profile_name = existing_publish_profile_name + logger.warning("Existing workflow file found") + if force: + logger.warning("Replacing the existing workflow file") + github_repo.update_file(path=file_path, message="Update workflow using Azure CLI", + content=completed_workflow_file, sha=existing_workflow_file.sha, branch=branch) + else: + option = prompt_y_n('Replace existing workflow file?') + if option: + logger.warning("Replacing the existing workflow file") + github_repo.update_file(path=file_path, message="Update workflow using Azure CLI", + content=completed_workflow_file, sha=existing_workflow_file.sha, + branch=branch) + else: + logger.warning("Use the existing workflow file") + if existing_publish_profile_name: + publish_profile_name = existing_publish_profile_name + except UnknownObjectException: + logger.warning("Creating new workflow file: %s", file_path) + github_repo.create_file(path=file_path, message="Create workflow using Azure CLI", + content=completed_workflow_file, branch=branch) + + # Add publish profile to GitHub + logger.warning('Adding publish profile to GitHub') + _add_publish_profile_to_github(cmd=cmd, resource_group=resource_group, name=name, repo=repo, + token=token, github_actions_secret_name=publish_profile_name, + slot=slot) + + # Set site source control properties + _update_site_source_control_properties_for_gh_action( + cmd=cmd, resource_group=resource_group, name=name, token=token, repo=repo, branch=branch, slot=slot) + + cache_github_token(cmd, token, repo) + github_actions_url = "https://github.com/{}/actions".format(repo) + return github_actions_url + + +def remove_functionapp_github_actions(cmd, resource_group, name, repo, token=None, slot=None, # pylint: disable=too-many-statements + branch='master', login_with_github=False): + if login_with_github: + token = get_github_access_token(cmd, ["admin:repo_hook", "repo", "workflow"], token) + repo = repo_url_to_name(repo) + token = get_token(cmd, repo, token) + # Verify resource group, app + site_availability = get_site_availability(cmd, name) + if site_availability.name_available or (not site_availability.name_available and + site_availability.reason == 'Invalid'): + raise ResourceNotFoundError("The Resource 'Microsoft.Web/sites/%s' under resource group '%s' was not found." % + (name, resource_group)) + app_details = get_app_details(cmd, name) + if app_details is None: + raise ResourceNotFoundError("Unable to retrieve details of the existing app %s. " + "Please check that the app is a part of the current subscription" % name) + current_rg = app_details.resource_group + if resource_group is not None and (resource_group.lower() != current_rg.lower()): + raise ValidationError("The functionapp %s exists in ResourceGroup %s and does not match " + "the value entered %s. Please re-run command with the correct " + "parameters." % (name, current_rg, resource_group)) + + # Verify github repo + from github import Github, GithubException + from github.GithubException import BadCredentialsException, UnknownObjectException + + if repo.strip()[-1] == '/': + repo = repo.strip()[:-1] + + g = Github(token) + github_repo = None + try: + github_repo = g.get_repo(repo) + try: + github_repo.get_branch(branch=branch) + except GithubException as e: + error_msg = "Encountered GitHub error when accessing {} branch in {} repo.".format(branch, repo) + if e.data and e.data['message']: + error_msg += " Error: {}".format(e.data['message']) + raise ValidationError(error_msg) + logger.warning('Verified GitHub repo and branch') + except BadCredentialsException: + raise ValidationError("Could not authenticate to the repository. Please create a Personal Access Token and use " + "the --token argument. Run 'az functionapp deployment github-actions remove --help' " + "for more information.") + except GithubException as e: + error_msg = "Encountered GitHub error when accessing {} repo".format(repo) + if e.data and e.data['message']: + error_msg += " Error: {}".format(e.data['message']) + raise ValidationError(error_msg) + + # Check if workflow exists in repo and remove + file_name = "{}_{}({}).yml".format( + branch.replace('/', '-'), name.lower(), slot) if slot else "{}_{}.yml".format( + branch.replace('/', '-'), name.lower()) + dir_path = "{}/{}".format('.github', 'workflows') + file_path = "{}/{}".format(dir_path, file_name) + existing_publish_profile_name = None + try: + existing_workflow_file = github_repo.get_contents(path=file_path, ref=branch) + existing_publish_profile_name = _get_publish_profile_from_workflow_file( + workflow_file=str(existing_workflow_file.decoded_content)) + logger.warning("Removing the existing workflow file") + github_repo.delete_file(path=file_path, message="Removing workflow file, disconnecting github actions", + sha=existing_workflow_file.sha, branch=branch) + except UnknownObjectException as e: + error_msg = "Error when removing workflow file." + if e.data and e.data['message']: + error_msg += " Error: {}".format(e.data['message']) + raise FileOperationError(error_msg) + + # Remove publish profile from GitHub + if existing_publish_profile_name: + logger.warning('Removing publish profile from GitHub') + _remove_publish_profile_from_github(cmd=cmd, resource_group=resource_group, name=name, repo=repo, token=token, + github_actions_secret_name=existing_publish_profile_name, slot=slot) + + # Remove site source control properties + delete_source_control(cmd=cmd, + resource_group_name=resource_group, + name=name, + slot=slot) + + return "Disconnected successfully." + + def _get_publish_profile_from_workflow_file(workflow_file): import re publish_profile = None @@ -5328,6 +5591,27 @@ def _get_workflow_template(github, runtime_string, is_linux): return file_contents +def _get_functionapp_workflow_template(github, runtime_string, is_linux): + from github import GithubException + + file_contents = None + template_repo_path = 'Azure/actions-workflow-samples' + template_path_map = (LINUX_FUNCTIONAPP_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH if is_linux else + WINDOWS_FUNCTIONAPP_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH) + template_file_path = _get_functionapp_template_file_path(runtime_string=runtime_string, + template_path_map=template_path_map) + + try: + template_repo = github.get_repo(template_repo_path) + file_contents = template_repo.get_contents(template_file_path) + except GithubException as e: + error_msg = "Encountered GitHub error when retrieving workflow template" + if e.data and e.data['message']: + error_msg += ": {}".format(e.data['message']) + raise CLIError(error_msg) + return file_contents + + def _fill_workflow_template(content, name, branch, slot, publish_profile, version): if not slot: slot = 'production' @@ -5344,6 +5628,23 @@ def _fill_workflow_template(content, name, branch, slot, publish_profile, versio return content +def _fill_functionapp_workflow_template(content, name, build_path, version, publish_profile): + content = content.replace("AZURE_FUNCTIONAPP_PUBLISH_PROFILE", f"{publish_profile}") + content = content.replace("AZURE_FUNCTIONAPP_NAME: your-app-name", f"AZURE_FUNCTIONAPP_NAME: '{name}'") + content = content.replace("POM_FUNCTIONAPP_NAME: your-app-name", f"POM_FUNCTIONAPP_NAME: '{name}'") + if "AZURE_FUNCTIONAPP_PACKAGE_PATH" not in content and "POM_XML_DIRECTORY" not in content: + logger.warning("Runtime does not support --build-path, ignoring value.") + content = content.replace("AZURE_FUNCTIONAPP_PACKAGE_PATH: '.'", f"AZURE_FUNCTIONAPP_PACKAGE_PATH: '{build_path}'") + content = content.replace("POM_XML_DIRECTORY: '.'", f"POM_XML_DIRECTORY: '{build_path}'") + content = content.replace("runs-on: ubuntu-18.04", "") # repair linux python yaml + if version: + content = content.replace("DOTNET_VERSION: '2.2.402'", f"DOTNET_VERSION: '{version}'") + content = content.replace("JAVA_VERSION: '1.8.x'", f"JAVA_VERSION: '{version}'") + content = content.replace("NODE_VERSION: '10.x'", f"NODE_VERSION: '{version}'") + content = content.replace("PYTHON_VERSION: '3.7'", f"PYTHON_VERSION: '{version}'") + return content + + def _get_template_file_path(runtime_string, is_linux): if not runtime_string: raise ResourceNotFoundError('Unable to retrieve workflow template') @@ -5370,6 +5671,21 @@ def _get_template_file_path(runtime_string, is_linux): return template_file_path +def _get_functionapp_template_file_path(runtime_string, template_path_map): + if not runtime_string: + raise ResourceNotFoundError('Unable to retrieve workflow template') + + runtime_string = runtime_string.lower() + runtime_stack = runtime_string.split('|')[0] + template_file_path = None + + template_file_path = template_path_map.get(runtime_stack) + + if not template_file_path: + raise ResourceNotFoundError('Unable to retrieve workflow template.') + return template_file_path + + def _add_publish_profile_to_github(cmd, resource_group, name, repo, token, github_actions_secret_name, slot=None): # Get publish profile with secrets import requests @@ -5428,6 +5744,33 @@ def _runtime_supports_github_actions(cmd, runtime_string, is_linux): return False +def _get_functionapp_runtime_version(cmd, runtime_string, runtime_version, functionapp_version, is_linux): + import re + runtime_version = re.sub(r"[^\d\.]", "", runtime_version).rstrip('.') + matched_runtime = None + helper = _FunctionAppStackRuntimeHelper(cmd, linux=(is_linux), windows=(not is_linux)) + try: + matched_runtime = helper.resolve(runtime_string, runtime_version, functionapp_version, is_linux) + except ValidationError as e: + if "Invalid version" in e.error_msg: + index = e.error_msg.index("Run 'az functionapp list-runtimes' for more details on supported runtimes.") + error_message = e.error_msg[0:index] + error_message += "Try passing --runtime-version with a supported version, or " + error_message += e.error_msg[index:].lower() + raise ValidationError(error_message) + raise e + if not matched_runtime: + return None + if matched_runtime.github_actions_properties: + gh_props = matched_runtime.github_actions_properties + if gh_props.is_supported: + if not is_linux and runtime_string.lower() == "powershell": + return runtime_version + # when stacks api is fixed, return supported_version if not null else runtime_verson + return gh_props.supported_version + return None + + def _get_app_runtime_info(cmd, resource_group, name, slot, is_linux): app_settings = None app_runtime = None @@ -5468,6 +5811,60 @@ def _get_app_runtime_info(cmd, resource_group, name, slot, is_linux): return _get_app_runtime_info_helper(cmd, app_runtime, app_runtime_version, is_linux) +def _get_functionapp_runtime_info(cmd, resource_group, name, slot, is_linux): # pylint: disable=too-many-return-statements + app_settings = None + app_runtime = None + functionapp_version = None + app_runtime_version = None + + app_settings = get_app_settings(cmd=cmd, resource_group_name=resource_group, name=name, slot=slot) + for app_setting in app_settings: + if 'name' in app_setting and app_setting['name'] == 'FUNCTIONS_EXTENSION_VERSION': + functionapp_version = app_setting["value"] + break + + if is_linux: + app_metadata = get_site_configs(cmd=cmd, resource_group_name=resource_group, name=name, slot=slot) + app_runtime = getattr(app_metadata, 'linux_fx_version', None) + return _get_functionapp_runtime_info_helper(cmd, app_runtime, None, functionapp_version, is_linux) + + app_settings = get_app_settings(cmd=cmd, resource_group_name=resource_group, name=name, slot=slot) + for app_setting in app_settings: + if 'name' in app_setting and app_setting['name'] == 'FUNCTIONS_WORKER_RUNTIME': + app_runtime = app_setting["value"] + break + + if app_runtime and app_runtime.lower() == 'node': + app_settings = get_app_settings(cmd=cmd, resource_group_name=resource_group, name=name, slot=slot) + for app_setting in app_settings: + if 'name' in app_setting and app_setting['name'] == 'WEBSITE_NODE_DEFAULT_VERSION': + app_runtime_version = app_setting['value'] if 'value' in app_setting else None + if app_runtime_version: + return _get_functionapp_runtime_info_helper(cmd, app_runtime, app_runtime_version, + functionapp_version, is_linux) + elif app_runtime and app_runtime.lower() == 'python': + app_settings = get_site_configs(cmd=cmd, resource_group_name=resource_group, name=name, slot=slot) + app_runtime_version = getattr(app_settings, 'python_version', '') + return _get_functionapp_runtime_info_helper(cmd, app_runtime, app_runtime_version, functionapp_version, + is_linux) + elif app_runtime and app_runtime.lower() == 'dotnet': + app_settings = get_site_configs(cmd=cmd, resource_group_name=resource_group, name=name, slot=slot) + app_runtime_version = getattr(app_settings, 'net_framework_version', '') + return _get_functionapp_runtime_info_helper(cmd, app_runtime, app_runtime_version, functionapp_version, + is_linux) + elif app_runtime and app_runtime.lower() == 'java': + app_settings = get_site_configs(cmd=cmd, resource_group_name=resource_group, name=name, slot=slot) + app_runtime_version = getattr(app_settings, 'java_version', '').lower() + return _get_functionapp_runtime_info_helper(cmd, app_runtime, app_runtime_version, functionapp_version, + is_linux) + elif app_runtime and app_runtime.lower() == 'powershell': + app_settings = get_site_configs(cmd=cmd, resource_group_name=resource_group, name=name, slot=slot) + app_runtime_version = getattr(app_settings, 'power_shell_version', '').lower() + return _get_functionapp_runtime_info_helper(cmd, app_runtime, app_runtime_version, functionapp_version, + is_linux) + return _get_functionapp_runtime_info_helper(cmd, app_runtime, app_runtime_version, functionapp_version, is_linux) + + def _get_app_runtime_info_helper(cmd, app_runtime, app_runtime_version, is_linux): helper = _StackRuntimeHelper(cmd, linux=(is_linux), windows=(not is_linux)) if not is_linux: @@ -5490,6 +5887,28 @@ def _get_app_runtime_info_helper(cmd, app_runtime, app_runtime_version, is_linux return None +def _get_functionapp_runtime_info_helper(cmd, app_runtime, app_runtime_version, functionapp_version, is_linux): + import re + + if is_linux: + if len(app_runtime.split('|')) < 2: + raise ValidationError(f"Runtime {app_runtime} is not supported.") + app_runtime_version = app_runtime.split('|')[1] + app_runtime = app_runtime.split('|')[0].lower() + + # Normalize versions + functionapp_version = functionapp_version if functionapp_version else "" + app_runtime_version = app_runtime_version if app_runtime_version else "" + functionapp_version = re.sub(r"[^\d\.]", "", functionapp_version) + app_runtime_version = re.sub(r"[^\d\.]", "", app_runtime_version) + + return { + "app_runtime": app_runtime, + "app_runtime_version": app_runtime_version, + "functionapp_version": functionapp_version + } + + def _encrypt_github_actions_secret(public_key, secret_value): # Encrypt a Unicode string using the public key from base64 import b64encode diff --git a/src/azure-cli/azure/cli/command_modules/appservice/utils.py b/src/azure-cli/azure/cli/command_modules/appservice/utils.py index 62a1280ce2f..c4f2510ead0 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/utils.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/utils.py @@ -19,6 +19,7 @@ from msrestazure.tools import parse_resource_id, is_valid_resource_id, resource_id from ._client_factory import web_client_factory +from ._constants import LOGICAPP_KIND, FUNCTIONAPP_KIND logger = get_logger(__name__) @@ -227,3 +228,41 @@ def use_additional_properties(resource): resource.enable_additional_properties_sending() existing_properties = resource.serialize().get("properties") resource.additional_properties["properties"] = {} if existing_properties is None else existing_properties + + +def repo_url_to_name(repo_url): + repo = None + repo = [s for s in repo_url.split('/') if s] + if len(repo) >= 2: + repo = '/'.join(repo[-2:]) + return repo + + +def get_token(cmd, repo, token): + from ._github_oauth import load_github_token_from_cache, get_github_access_token + if not repo: + return None + if token: + return token + token = load_github_token_from_cache(cmd, repo) + if not token: + token = get_github_access_token(cmd, ["admin:repo_hook", "repo", "workflow"], token) + return token + + +def is_logicapp(app): + if app is None or app.kind is None: + return False + return LOGICAPP_KIND in app.kind + + +def is_functionapp(app): + if app is None or app.kind is None: + return False + return not is_logicapp(app) and FUNCTIONAPP_KIND in app.kind + + +def is_webapp(app): + if app is None or app.kind is None: + return False + return not is_logicapp(app) and not is_functionapp(app) and "app" in app.kind