diff --git a/src/webapp/azext_webapp/__init__.py b/src/webapp/azext_webapp/__init__.py index 69d1f948171..e7bfd26892e 100644 --- a/src/webapp/azext_webapp/__init__.py +++ b/src/webapp/azext_webapp/__init__.py @@ -7,7 +7,7 @@ from azure.cli.core import AzCommandsLoader from azure.cli.command_modules.appservice.commands import ex_handler_factory from knack.arguments import CLIArgumentType -from azure.cli.core.commands.parameters import (get_resource_name_completion_list) +from azure.cli.core.commands.parameters import (resource_group_name_type, get_resource_name_completion_list) import azext_webapp._help @@ -34,6 +34,10 @@ def load_command_table(self, _): g.custom_command('list-result', 'get_all_scan_result') g.custom_command('stop', 'stop_scan') + with self.command_group('webapp deployment github-actions') as g: + g.custom_command('add', 'add_github_actions') + g.custom_command('remove', 'remove_github_actions') + return self.command_table def load_arguments(self, _): @@ -77,5 +81,18 @@ def load_arguments(self, _): c.argument('timeout', options_list=['--timeout'], help='Timeout for operation in milliseconds') c.argument('slot', help="Name of the deployment slot to use") + with self.argument_context('webapp deployment github-actions')as c: + c.argument('name', arg_type=webapp_name_arg_type) + c.argument('resource_group', arg_type=resource_group_name_type, options_list=['--resource-group', '-g']) + c.argument('repo', help='The GitHub repository to which the workflow file will be added. In the format: /') + 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') + 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. Defaults to "master" if not specified.') + c.argument('force', options_list=['--force', '-f'], help='When true, the command will overwrite any workflow file with a conflicting name.') + + with self.argument_context('webapp deployment github-actions add')as c: + c.argument('runtime', options_list=['--runtime', '-r'], help='Canonicalized web runtime in the format of Framework|Version, e.g. "PHP|5.6". Use "az webapp list-runtimes" for available list.') + c.argument('force', options_list=['--force', '-f'], help='When true, the command will overwrite any workflow file with a conflicting name.') + COMMAND_LOADER_CLS = WebappExtCommandLoader diff --git a/src/webapp/azext_webapp/_constants.py b/src/webapp/azext_webapp/_constants.py new file mode 100644 index 00000000000..d3214ca7884 --- /dev/null +++ b/src/webapp/azext_webapp/_constants.py @@ -0,0 +1,188 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +LINUX_GITHUB_ACTIONS_SUPPORTED_STACKS = [ + 'NODE|12-lts', + 'NODE|10-lts', + 'NODE|10.14', + 'NODE|10.6', + 'NODE|10.1', + 'PYTHON|3.8', + 'PYTHON|3.7', + 'PYTHON|3.6', + 'DOTNETCORE|2.1', + 'DOTNETCORE|3.1', + 'JAVA|8-jre8', + 'TOMCAT|8.5-jre8', + 'TOMCAT|9.0-jre8', + 'JAVA|11-java11', + 'TOMCAT|8.5-java11', + 'TOMCAT|9.0-java11' +] + +WINDOWS_GITHUB_ACTIONS_SUPPORTED_STACKS = [ + 'node|12-lts', + 'node|10.6', + 'node|10.14', + 'python|3.6', + 'DOTNETCORE|2.1', + 'DOTNETCORE|3.1', + 'java|1.8|Tomcat|8.5', + 'java|1.8|Tomcat|9.0', + 'java|1.8|Java SE|8', + 'java|11|Tomcat|8.5', + 'java|11|Tomcat|9.0', + 'java|11|Java SE|8' +] + +LINUX_RUNTIME_STACK_INFO = { + 'node|12-lts': { + 'display_name': 'NODE|12-lts', + 'github_actions_version': '12.x' + }, + 'node|10-lts': { + 'display_name': 'NODE|10-lts', + 'github_actions_version': '10.x' + }, + 'node|10.14': { + 'display_name': 'NODE|10.14', + 'github_actions_version': '10.14' + }, + 'node|10.10': { + 'display_name': 'NODE|10.10', + 'github_actions_version': '10.10' + }, + 'node|10.6': { + 'display_name': 'NODE|10.6', + 'github_actions_version': '10.6' + }, + 'node|10.1': { + 'display_name': 'NODE|10.1', + 'github_actions_version': '10.1' + }, + 'python|3.8': { + 'display_name': 'PYTHON|3.8', + 'github_actions_version': '3.8' + }, + 'python|3.7': { + 'display_name': 'PYTHON|3.7', + 'github_actions_version': '3.7' + }, + 'python|3.6': { + 'display_name': 'PYTHON|3.6', + 'github_actions_version': '3.6' + }, + 'dotnetcore|2.1': { + 'display_name': 'DOTNETCORE|2.1', + 'github_actions_version': '2.1.804' + }, + 'dotnetcore|3.1': { + 'display_name': 'DOTNETCORE|3.1', + 'github_actions_version': '3.1.102' + }, + 'java|8-jre8': { + 'display_name': 'JAVA|8-jre8', + 'github_actions_version': '8' + }, + 'tomcat|8.5-jre8': { + 'display_name': 'TOMCAT|8.5-jre8', + 'github_actions_version': '8' + }, + 'tomcat|9.0-jre8': { + 'display_name': 'TOMCAT|9.0-jre8', + 'github_actions_version': '8' + }, + 'java|11-java11': { + 'display_name': 'JAVA|11-java11', + 'github_actions_version': '11' + }, + 'tomcat|8.5-java11': { + 'display_name': 'TOMCAT|8.5-java11', + 'github_actions_version': '11' + }, + 'tomcat|9.0-java11': { + 'display_name': 'TOMCAT|9.0-java11', + 'github_actions_version': '11' + } +} + +WINDOWS_RUNTIME_STACK_INFO = { + 'node': { + '12.13.0': { # WEBSITE_NODE_DEFAULT_VERSION + 'display_name': 'node|12-lts', + 'github_actions_version': '12.13.0' + }, + '10.0.0': { + 'display_name': 'node|10.0', + 'github_actions_version': '10.0.0' + }, + '10.6.0': { + 'display_name': 'node|10.6', + 'github_actions_version': '10.6.0' + }, + '10.14.1': { + 'display_name': 'node|10.14', + 'github_actions_version': '10.14.1' + } + }, + 'python': { + '3.4': { # python_version + 'display_name': 'python|3.6', + 'github_actions_version': '3.6' + } + }, + 'dotnetcore': { + '2.1': { # dotnetcore_version + 'display_name': 'DOTNETCORE|2.1', + 'github_actions_version': '3.1.102' + }, + '3.1': { + 'display_name': 'DOTNETCORE|3.1', + 'github_actions_version': '2.1.804' + } + }, + 'java': { + ('1.8', 'tomcat', '8.5'): { # (java_version, java_container, java_container_version) + 'display_name': 'java|1.8|Tomcat|8.5', + 'github_actions_version': '8' + }, + ('1.8', 'tomcat', '9.0'): { + 'display_name': 'java|1.8|Tomcat|9.0', + 'github_actions_version': '8' + }, + ('1.8', 'java', 'se'): { + 'display_name': 'java|1.8|Java SE|8', + 'github_actions_version': '8' + }, + ('11', 'tomcat', '8.5'): { + 'display_name': 'java|11|Tomcat|8.5', + 'github_actions_version': '11' + }, + ('11', 'tomcat', '9.0'): { + 'display_name': 'java|11|Tomcat|9.0', + 'github_actions_version': '11' + }, + ('11', 'java', 'se'): { + 'display_name': 'java|11|Java SE|8', + 'github_actions_version': '11' + } + } +} + +LINUX_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH = { + 'node': 'AppService/linux/nodejs-webapp-on-azure.yml', + 'python': 'AppService/linux/python-webapp-on-azure.yml', + 'dotnetcore': 'AppService/linux/aspnet-core-webapp-on-azure.yml', + 'java': 'AppService/linux/java-jar-webapp-on-azure.yml', + 'tomcat': 'AppService/linux/java-war-webapp-on-azure.yml' +} + +WINDOWS_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH = { + 'node': 'AppService/windows/nodejs-webapp-on-azure.yml', + 'python': 'AppService/windows/python-webapp-on-azure.yml', + 'dotnetcore': 'AppService/windows/aspnet-core-webapp-on-azure.yml', + 'java': 'AppService/windows/java-jar-webapp-on-azure.yml', + 'tomcat': 'AppService/windows/java-war-webapp-on-azure.yml' +} \ No newline at end of file diff --git a/src/webapp/azext_webapp/_help.py b/src/webapp/azext_webapp/_help.py index 082fcc234b0..8bdbf3de947 100644 --- a/src/webapp/azext_webapp/_help.py +++ b/src/webapp/azext_webapp/_help.py @@ -69,3 +69,13 @@ - name: Deploy a static text file to wwwroot/staticfiles/test.txt text: az webapp deploy --resource-group ResouceGroup --name AppName --src-path SourcePath --type static --target-path staticfiles/test.txt """ + +helps['webapp deployment github-actions add'] = """ + type: command + short-summary: Adds a GitHub Actions workflow file to the specified repository. The workflow will build and deploy your app to the specified webapp. +""" + +helps['webapp deployment github-actions remove'] = """ + type: command + short-summary: Removes and disconnects the GitHub Actions workflow file from the specified repository. +""" diff --git a/src/webapp/azext_webapp/custom.py b/src/webapp/azext_webapp/custom.py index 52af6b3cc19..62369a7adee 100644 --- a/src/webapp/azext_webapp/custom.py +++ b/src/webapp/azext_webapp/custom.py @@ -4,9 +4,13 @@ # -------------------------------------------------------------------------------------------- from __future__ import print_function +from base64 import b64encode from knack.log import get_logger +from knack.prompting import prompt_y_n from knack.util import CLIError -from azure.mgmt.web.models import (AppServicePlan, SkuDescription) +from nacl import encoding, public +import requests +from azure.mgmt.web.models import (AppServicePlan, SkuDescription, SiteSourceControl) from azure.cli.command_modules.appservice.custom import ( show_webapp, _get_site_credential, @@ -16,15 +20,32 @@ update_container_settings, create_webapp, get_sku_name, - _check_zip_deployment_status) + _check_zip_deployment_status, + get_app_settings, + config_source_control, + delete_source_control, + show_source_control) from azure.cli.command_modules.appservice._appservice_utils import _generic_site_operation from azure.cli.command_modules.appservice._create_util import ( should_create_new_rg, create_resource_group, web_client_factory, - should_create_new_app + should_create_new_app, + get_app_details, + get_site_availability +) +from azure.cli.command_modules.appservice._github_oauth import (get_github_access_token) +from azure.cli.core.commands import LongRunningOperation +from ._constants import ( + LINUX_GITHUB_ACTIONS_SUPPORTED_STACKS, + WINDOWS_GITHUB_ACTIONS_SUPPORTED_STACKS, + LINUX_RUNTIME_STACK_INFO, + WINDOWS_RUNTIME_STACK_INFO, + LINUX_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH, + WINDOWS_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH ) from .acr_util import (queue_acr_build, generate_img_name) +from msrestazure.tools import parse_resource_id logger = get_logger(__name__) @@ -490,7 +511,7 @@ def _make_onedeploy_request(params): # check the status of async deployment if response.status_code == 202: if poll_async_deployment_for_debugging: - logger.info('Polloing the status of async deployment') + logger.info('Polling the status of async deployment') response_body = _check_zip_deployment_status(params.cmd, params.resource_group_name, params.webapp_name, deployment_status_url, headers, params.timeout) logger.info('Async deployment complete. Server response: %s', response_body) return @@ -525,3 +546,412 @@ def _perform_onedeploy_internal(params): _make_onedeploy_request(params) return logger.info("Deployment has completed successfully") + + +def add_github_actions(cmd, resource_group, name, repo, runtime=None, token=None, slot=None, branch='master', force=False): + if not token: + token = get_github_access_token(cmd, ['admin:repo_hook', 'repo', 'workflow']) + if not token: + raise CLIError("Could not authenticate to the repository. Please create a Personal Access Token and use the --token argument. Run 'az webapp deployment github-actions add --help' for more information.") + + # 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 CLIError("The Resource 'Microsoft.Web/sites/{}' under resource group '{}' was not found.".format(name, resource_group)) + app_details = get_app_details(cmd, name) + if app_details is None: + raise CLIError("Unable to retrieve details of the existing app {}. Please check that the app is a part of the current subscription".format(name)) + current_rg = app_details.resource_group + if resource_group is not None and (resource_group.lower() != current_rg.lower()): + raise CLIError("The webapp {} exists in ResourceGroup {} and does not match the value entered {}. Please " + "re-run command with the correct parameters.".format(name, current_rg, resource_group)) + parsed_plan_id = parse_resource_id(app_details.server_farm_id) + client = web_client_factory(cmd.cli_ctx) + plan_info = client.app_service_plans.get(parsed_plan_id['resource_group'], parsed_plan_id['name']) + is_linux = plan_info.reserved + + # Verify github repo + from github import Github, GithubException + from github.GithubException import BadCredentialsException, UnknownObjectException + import yaml + + if repo.strip()[-1] == '/': + repo = repo.strip()[:-1] + + g = Github(token) + github_repo = None + github_branch = None + try: + github_repo = g.get_repo(repo) + try: + github_branch = 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 CLIError(error_msg) + logger.warning('Verified GitHub repo and branch') + except BadCredentialsException as e: + raise CLIError("Could not authenticate to the repository. Please create a Personal Access Token and use the --token argument. Run 'az webapp 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 CLIError(error_msg) + + # Verify runtime + app_runtime_info = _get_app_runtime_info(cmd=cmd, resource_group=resource_group, name=name, slot=slot, is_linux=is_linux) + app_runtime_string = app_runtime_info['display_name'] if (app_runtime_info and app_runtime_info['display_name']) else None + github_actions_version = app_runtime_info['github_actions_version'] if (app_runtime_info and app_runtime_info['github_actions_version']) else None + + if runtime and app_runtime_string: + if app_runtime_string.lower() != runtime.lower(): + logger.warning('The app runtime: {} does not match the runtime specified: {}. Using the specified runtime {}.'.format(app_runtime_string, runtime, runtime)) + app_runtime_string = runtime + elif runtime: + app_runtime_string = runtime + + if not app_runtime_string: + raise CLIError('Could not detect runtime. Please specify using the --runtime flag.') + + if not _runtime_supports_github_actions(runtime_string=app_runtime_string, is_linux=is_linux): + raise CLIError("Runtime {} is not supported for GitHub Actions deployments.".format(app_runtime_string)) + + # Get workflow template + logger.warning('Getting workflow template using runtime: {}'.format(app_runtime_string)) + workflow_template = _get_workflow_template(github=g, runtime_string=app_runtime_string, is_linux=is_linux) + + # Fill workflow template + import uuid + guid = str(uuid.uuid4()).replace('-', '') + publish_profile_name = "AzureAppService_PublishProfile_{}".format(guid) + logger.warning('Fill workflow template with name: {}, branch: {}, version: {}, slot: {}'.format(name, branch, github_actions_version, slot if slot else 'production')) + completed_workflow_file = _fill_workflow_template(content=workflow_template.decoded_content.decode(), name=name, + branch=branch, slot=slot, publish_profile=publish_profile_name, version=github_actions_version) + completed_workflow_file = completed_workflow_file.encode() + + # Check if workflow exists in repo, otherwise push + 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) + 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 as e: + logger.warning("Creating new workflow file: {}".format(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('Add 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) + + github_actions_url = "https://github.com/{}/actions".format(repo) + return github_actions_url + + +def remove_github_actions(cmd, resource_group, name, repo, token=None, slot=None, branch='master'): + if not token: + token = get_github_access_token(cmd, ['admin:repo_hook', 'repo', 'workflow']) + if not token: + raise CLIError("Could not authenticate to the repository. Please create a Personal Access Token and use the --token argument. Run 'az webapp deployment github-actions add --help' for more information.") + + # 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 CLIError("The Resource 'Microsoft.Web/sites/{}' under resource group '{}' was not found.".format(name, resource_group)) + app_details = get_app_details(cmd, name) + if app_details is None: + raise CLIError("Unable to retrieve details of the existing app {}. Please check that the app is a part of the current subscription".format(name)) + current_rg = app_details.resource_group + if resource_group is not None and (resource_group.lower() != current_rg.lower()): + raise CLIError("The webapp {} exists in ResourceGroup {} and does not match the value entered {}. Please " + "re-run command with the correct parameters.".format(name, current_rg, resource_group)) + + # Verify github repo + from github import Github, GithubException + from github.GithubException import BadCredentialsException, UnknownObjectException + import yaml + + if repo.strip()[-1] == '/': + repo = repo.strip()[:-1] + + g = Github(token) + github_repo = None + github_branch = None + try: + github_repo = g.get_repo(repo) + try: + github_branch = 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 CLIError(error_msg) + logger.warning('Verified GitHub repo and branch') + except BadCredentialsException as e: + raise CLIError("Could not authenticate to the repository. Please create a Personal Access Token and use the --token argument. Run 'az webapp 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 CLIError(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 CLIError(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 + regex = re.search(r'publish-profile: \$\{\{ secrets\..*?\}\}', workflow_file) + if regex: + publish_profile = regex.group() + publish_profile = publish_profile.replace('publish-profile: ${{ secrets.', '') + publish_profile = publish_profile[:-2] + + if publish_profile: + return publish_profile.strip() + return None + + +def _update_site_source_control_properties_for_gh_action(cmd, resource_group, name, token, repo=None, branch="master", slot=None): + if repo: + repo_url = 'https://github.com/' + repo + else: + repo_url = None + + site_source_control = show_source_control(cmd=cmd, + resource_group_name=resource_group, + name=name, + slot=slot) + if site_source_control: + if not repo_url: + repo_url = site_source_control.repo_url + + delete_source_control(cmd=cmd, + resource_group_name=resource_group, + name=name, + slot=slot) + config_source_control(cmd=cmd, + resource_group_name=resource_group, + name=name, + repo_url=repo_url, + repository_type='github', + github_action=True, + branch=branch, + git_token=token, + slot=slot) + + +def _get_workflow_template(github, runtime_string, is_linux): + from github import GithubException + from github.GithubException import BadCredentialsException + + file_contents = None + template_repo_path = 'Azure/actions-workflow-templates' + template_file_path = _get_template_file_path(runtime_string=runtime_string, is_linux=is_linux) + + try: + template_repo = github.get_repo(template_repo_path) + file_contents = template_repo.get_contents(template_file_path) + except BadCredentialsException as e: + raise CLIError("Could not authenticate to the repository. Please create a Personal Access Token and use the --token argument. Run 'az webapp deployment github-actions add --help' for more information.") + 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' + + content = content.replace('${web-app-name}', name) + content = content.replace('${branch}', branch) + content = content.replace('${slot-name}', slot) + content = content.replace('${azure-webapp-publish-profile-name}', publish_profile) + content = content.replace('${AZURE_WEBAPP_PUBLISH_PROFILE}', publish_profile) + content = content.replace('${dotnet-core-version}', version) + content = content.replace('${java-version}', version) + content = content.replace('${node-version}', version) + content = content.replace('${python-version}', version) + return content + + +def _get_template_file_path(runtime_string, is_linux): + if not runtime_string: + raise CLIError('Unable to retrieve workflow template') + + runtime_string = runtime_string.lower() + runtime_stack = runtime_string.split('|')[0] + template_file_path = None + + if is_linux: + template_file_path = LINUX_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH.get(runtime_stack, None) + else: + # Handle java naming + if runtime_stack == 'java': + java_container_split = runtime_string.split('|') + if java_container_split and len(java_container_split) >= 2: + if java_container_split[2] == 'tomcat': + runtime_stack = 'tomcat' + elif java_container_split[2] == 'java se': + runtime_stack = 'java' + template_file_path = WINDOWS_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH.get(runtime_stack, None) + + if not template_file_path: + raise CLIError('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 + logger.warning("Fetching publish profile with secrets for the app '%s'", name) + publish_profile_bytes= _generic_site_operation(cmd.cli_ctx, resource_group, name, 'list_publishing_profile_xml_with_secrets', slot, {"format": "WebDeploy"}) + publish_profile = [x for x in publish_profile_bytes] + if publish_profile: + publish_profile = publish_profile[0].decode('ascii') + else: + raise CLIError('Unable to retrieve publish profile.') + + # Add publish profile with secrets as a GitHub Actions Secret in the repo + headers = {} + headers['Authorization'] = 'Token {}'.format(token) + headers['Content-Type'] = 'application/json;' + headers['Accept'] = 'application/json;' + + public_key_url = "https://api.github.com/repos/{}/actions/secrets/public-key".format(repo) + public_key = requests.get(public_key_url, headers=headers) + if not public_key.ok: + raise CLIError('Request to GitHub for public key failed.') + public_key = public_key.json() + + encrypted_github_actions_secret = _encrypt_github_actions_secret(public_key=public_key['key'], secret_value=str(publish_profile)) + payload = { + "encrypted_value": encrypted_github_actions_secret, + "key_id": public_key['key_id'] + } + + import json + store_secret_url = "https://api.github.com/repos/{}/actions/secrets/{}".format(repo, github_actions_secret_name) + stored_secret = requests.put(store_secret_url, data=json.dumps(payload), headers=headers) + if str(stored_secret.status_code)[0] != '2': + raise CLIError('Unable to add publish profile to GitHub. Request status code: {}'.format(stored_secret.status_code)) + + +def _remove_publish_profile_from_github(cmd, resource_group, name, repo, token, github_actions_secret_name, slot=None): + headers = {} + headers['Authorization'] = 'Token {}'.format(token) + + import json + store_secret_url = "https://api.github.com/repos/{}/actions/secrets/{}".format(repo, github_actions_secret_name) + requests.delete(store_secret_url, headers=headers) + + +def _runtime_supports_github_actions(runtime_string, is_linux): + if is_linux: + return runtime_string.lower() in [x.lower() for x in LINUX_GITHUB_ACTIONS_SUPPORTED_STACKS] + else: + return runtime_string.lower() in [x.lower() for x in WINDOWS_GITHUB_ACTIONS_SUPPORTED_STACKS] + + +def _get_app_runtime_info(cmd, resource_group, name, slot, is_linux): + app_settings = None + app_runtime = None + app_runtime_info = None + + 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) + if app_runtime and (app_runtime.lower() in LINUX_RUNTIME_STACK_INFO): + app_runtime_info = LINUX_RUNTIME_STACK_INFO[app_runtime.lower()] + else: + app_metadata = _generic_site_operation(cmd.cli_ctx, resource_group, name, 'list_metadata', slot) + app_metadata_properties = getattr(app_metadata, 'properties', {}) + if 'CURRENT_STACK' in app_metadata_properties: + app_runtime = app_metadata_properties['CURRENT_STACK'] + + 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: + app_runtime_info = WINDOWS_RUNTIME_STACK_INFO['node'].get(app_runtime_version.lower(), None) + 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', '') + app_runtime_info = WINDOWS_RUNTIME_STACK_INFO['python'].get(app_runtime_version.lower(), None) + elif app_runtime and app_runtime.lower() == 'dotnetcore': + app_runtime_version = '3.1' + app_runtime_info = WINDOWS_RUNTIME_STACK_INFO['dotnetcore'].get(app_runtime_version.lower(), None) + elif app_runtime and app_runtime.lower() == 'java': + app_settings = get_site_configs(cmd=cmd, resource_group_name=resource_group, name=name, slot=slot) + java_version = getattr(app_settings, 'java_version', '').lower() + java_container = getattr(app_settings, 'java_container', '').lower() + java_container_version = getattr(app_settings, 'java_container_version', '').lower() + app_runtime_info = WINDOWS_RUNTIME_STACK_INFO['java'].get((java_version, java_container, java_container_version), None) + return app_runtime_info + + +def _encrypt_github_actions_secret(public_key, secret_value): + """Encrypt a Unicode string using the public key.""" + public_key = public.PublicKey(public_key.encode("utf-8"), encoding.Base64Encoder()) + sealed_box = public.SealedBox(public_key) + encrypted = sealed_box.encrypt(secret_value.encode("utf-8")) + return b64encode(encrypted).decode("utf-8")