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 ed1923cec35..104a62bc658 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_constants.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_constants.py @@ -75,3 +75,19 @@ def __init__(self): '../resources/GenerateRandomAppNames.json')) PUBLIC_CLOUD = "AzureCloud" + +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' +} 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 c27c01309b8..ddf419d2393 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1715,6 +1715,35 @@ az webapp deployment user show """ +helps['webapp deployment github-actions'] = """ +type: group +short-summary: Configure GitHub Actions for a webapp +""" + +helps['webapp 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 webapp. +examples: + - name: Add GitHub Actions to a specified repository, providing personal access token + text: > + az webapp deployment github-actions add --repo "githubUser/githubRepo" -g MyResourceGroup -n MyWebapp --token MyPersonalAccessToken + - name: Add GitHub Actions to a specified repository, using interactive method of retrieving personal access token + text: > + az webapp deployment github-actions add --repo "githubUser/githubRepo" -g MyResourceGroup -n MyWebapp --login-with-github +""" + +helps['webapp 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 webapp deployment github-actions remove --repo "githubUser/githubRepo" -g MyResourceGroup -n MyWebapp --token MyPersonalAccessToken + - name: Remove GitHub Actions from a specified repository, using interactive method of retrieving personal access token + text: > + az webapp deployment github-actions remove --repo "githubUser/githubRepo" -g MyResourceGroup -n MyWebapp --login-with-github +""" + helps['webapp hybrid-connection'] = """ type: group short-summary: methods that list, add and remove hybrid-connections from webapps 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 c2329dd86e5..c436e08bb69 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -410,6 +410,20 @@ def load_arguments(self, _): c.argument('action', 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('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('login_with_github', help='Interactively log in with Github to retrieve the Personal Access Token', action='store_true') + + 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.', action='store_true') + with self.argument_context('webapp log config') as c: c.argument('application_logging', help='configure application logging', arg_type=get_enum_type(['filesystem', 'azureblobstorage', 'off'])) 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 e7549eca506..c177d132f44 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/commands.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/commands.py @@ -247,6 +247,10 @@ 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', is_preview=True) as g: + g.custom_command('add', 'add_github_actions') + g.custom_command('remove', 'remove_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 f6a2b01707c..1065fe1517e 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -19,12 +19,13 @@ import sys import uuid from functools import reduce +from nacl import encoding, public from six.moves.urllib.request import urlopen # pylint: disable=import-error, ungrouped-imports import OpenSSL.crypto from fabric import Connection -from knack.prompting import prompt_pass, NoTTYException +from knack.prompting import prompt_pass, NoTTYException, prompt_y_n from knack.util import CLIError from knack.log import get_logger @@ -54,14 +55,16 @@ from ._params import AUTH_TYPES, MULTI_CONTAINER_TYPES from ._client_factory import web_client_factory, ex_handler_factory, providers_client_factory from ._appservice_utils import _generic_site_operation, _generic_settings_operation -from .utils import _normalize_sku, get_sku_name, retryable_method +from .utils import _normalize_sku, get_sku_name, retryable_method, raise_missing_token_suggestion from ._create_util import (zip_contents_from_dir, get_runtime_version_details, create_resource_group, get_app_details, should_create_new_rg, set_location, get_site_availability, get_profile_username, get_plan_to_use, get_lang_from_content, get_rg_to_use, get_sku_to_use, detect_os_form_src, get_current_stack_from_runtime, generate_default_app_name) from ._constants import (FUNCTIONS_STACKS_API_JSON_PATHS, FUNCTIONS_STACKS_API_KEYS, FUNCTIONS_LINUX_RUNTIME_VERSION_REGEX, FUNCTIONS_WINDOWS_RUNTIME_VERSION_REGEX, - NODE_EXACT_VERSION_DEFAULT, RUNTIME_STACKS, FUNCTIONS_NO_V2_REGIONS, PUBLIC_CLOUD) + NODE_EXACT_VERSION_DEFAULT, RUNTIME_STACKS, FUNCTIONS_NO_V2_REGIONS, PUBLIC_CLOUD, + LINUX_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH, WINDOWS_GITHUB_ACTIONS_WORKFLOW_TEMPLATE_PATH) +from ._github_oauth import (get_github_access_token) logger = get_logger(__name__) @@ -4459,3 +4462,490 @@ def delete_function_key(cmd, resource_group_name, name, key_name, function_name= if slot: return client.web_apps.delete_function_secret_slot(resource_group_name, name, function_name, key_name, slot) return client.web_apps.delete_function_secret(resource_group_name, name, function_name, key_name) + + +def add_github_actions(cmd, resource_group, name, repo, runtime=None, token=None, slot=None, # pylint: disable=too-many-statements,too-many-branches + branch='master', login_with_github=False, force=False): + if not token and not login_with_github: + raise_missing_token_suggestion() + elif not token: + scopes = ["admin:repo_hook", "repo", "workflow"] + token = get_github_access_token(cmd, scopes) + elif token and login_with_github: + logger.warning("Both token and --login-with-github flag are provided. Will use provided 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)) + 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 + + 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 CLIError(error_msg) + logger.warning('Verified GitHub repo and branch') + except BadCredentialsException: + 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 = None + if(app_runtime_info and app_runtime_info['display_name']): + app_runtime_string = app_runtime_info['display_name'] + + github_actions_version = None + if (app_runtime_info and app_runtime_info['github_actions_version']): + github_actions_version = app_runtime_info['github_actions_version'] + + if runtime and app_runtime_string: + if app_runtime_string.lower() != runtime.lower(): + logger.warning('The app runtime: {app_runtime_string} does not match the runtime specified: ' + '{runtime}. Using the specified 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 %s is not supported for GitHub Actions deployments." % app_runtime_string) + + # Get workflow template + logger.warning('Getting workflow template using runtime: %s', app_runtime_string) + workflow_template = _get_workflow_template(github=g, runtime_string=app_runtime_string, is_linux=is_linux) + + # Fill workflow template + guid = str(uuid.uuid4()).replace('-', '') + publish_profile_name = "AzureAppService_PublishProfile_{}".format(guid) + logger.warning( + 'Filling workflow template with name: %s, branch: %s, version: %s, slot: %s', + 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 + 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) + + 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, # pylint: disable=too-many-statements + branch='master', login_with_github=False): + if not token and not login_with_github: + raise_missing_token_suggestion() + elif not token: + scopes = ["admin:repo_hook", "repo", "workflow"] + token = get_github_access_token(cmd, scopes) + elif token and login_with_github: + logger.warning("Both token and --login-with-github flag are provided. Will use provided 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 CLIError("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 CLIError("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 CLIError("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)) + + # 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 CLIError(error_msg) + logger.warning('Verified GitHub repo and branch') + except BadCredentialsException: + 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: + 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 + import requests + + 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 = list(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'] + } + + 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: %s' % 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 requests + 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: + stacks = get_file_json(RUNTIME_STACKS)['linux'] + else: + stacks = get_file_json(RUNTIME_STACKS)['windows'] + + supports = False + for stack in stacks: + if stack['displayName'].lower() == runtime_string.lower(): + if 'github_actions_properties' in stack and stack['github_actions_properties']: + supports = True + return supports + + +def _get_app_runtime_info(cmd, resource_group, name, slot, is_linux): + app_settings = None + app_runtime = 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) + return _get_app_runtime_info_helper(app_runtime, "", is_linux) + + 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: + return _get_app_runtime_info_helper(app_runtime, app_runtime_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_app_runtime_info_helper(app_runtime, app_runtime_version, is_linux) + elif app_runtime and app_runtime.lower() == 'dotnetcore': + app_runtime_version = '3.1' + app_runtime_version = "" + return _get_app_runtime_info_helper(app_runtime, app_runtime_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 = "{java_version}, {java_container}, {java_container_version}".format( + 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() + ) + return _get_app_runtime_info_helper(app_runtime, app_runtime_version, is_linux) + + +def _get_app_runtime_info_helper(app_runtime, app_runtime_version, is_linux): + if is_linux: + stacks = get_file_json(RUNTIME_STACKS)['linux'] + for stack in stacks: + if 'github_actions_properties' in stack and stack['github_actions_properties']: + if stack['displayName'].lower() == app_runtime.lower(): + return { + "display_name": stack['displayName'], + "github_actions_version": stack['github_actions_properties']['github_actions_version'] + } + else: + stacks = get_file_json(RUNTIME_STACKS)['windows'] + for stack in stacks: + if 'github_actions_properties' in stack and stack['github_actions_properties']: + if (stack['github_actions_properties']['app_runtime'].lower() == app_runtime.lower() and + stack['github_actions_properties']['app_runtime_version'].lower() == + app_runtime_version.lower()): + return { + "display_name": stack['displayName'], + "github_actions_version": stack['github_actions_properties']['github_actions_version'] + } + return None + + +def _encrypt_github_actions_secret(public_key, secret_value): + # Encrypt a Unicode string using the public key + from base64 import b64encode + 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") diff --git a/src/azure-cli/azure/cli/command_modules/appservice/resources/WebappRuntimeStacks.json b/src/azure-cli/azure/cli/command_modules/appservice/resources/WebappRuntimeStacks.json index 3411d0083d8..3801b6db10c 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/resources/WebappRuntimeStacks.json +++ b/src/azure-cli/azure/cli/command_modules/appservice/resources/WebappRuntimeStacks.json @@ -14,11 +14,21 @@ }, { "displayName": "DOTNETCORE|2.1", - "configs": {} + "configs": {}, + "github_actions_properties": { + "app_runtime": "dotnetcore", + "app_runtime_version": "", + "github_actions_version": "2.1.804" + } }, { "displayName": "DOTNETCORE|3.1", - "configs": {} + "configs": {}, + "github_actions_properties": { + "app_runtime": "dotnetcore", + "app_runtime_version": "", + "github_actions_version": "3.1.102" + } }, { "displayName": "DOTNET|5.0", @@ -36,30 +46,55 @@ "displayName": "node|10.6", "configs": { "WEBSITE_NODE_DEFAULT_VERSION": "10.6.0" + }, + "github_actions_properties": { + "app_runtime": "node", + "app_runtime_version": "10.6.0", + "github_actions_version": "10.6.0" } }, { "displayName": "node|10.10", "configs": { "WEBSITE_NODE_DEFAULT_VERSION": "10.0.0" + }, + "github_actions_properties": { + "app_runtime": "node", + "app_runtime_version": "10.0.0", + "github_actions_version": "10.0.0" } }, { "displayName": "node|10.14", "configs": { "WEBSITE_NODE_DEFAULT_VERSION": "10.14.1" + }, + "github_actions_properties": { + "app_runtime": "node", + "app_runtime_version": "10.14.1", + "github_actions_version": "10.14.1" } }, { "displayName": "node|12-lts", "configs": { "WEBSITE_NODE_DEFAULT_VERSION": "12.13.0" + }, + "github_actions_properties": { + "app_runtime": "node", + "app_runtime_version": "12.13.0", + "github_actions_version": "12.13.0" } }, { "displayName": "node|14-lts", "configs": { "WEBSITE_NODE_DEFAULT_VERSION": "~14" + }, + "github_actions_properties": { + "app_runtime": "node", + "app_runtime_version": "~14", + "github_actions_version": "14" } }, { @@ -78,6 +113,11 @@ "displayName": "python|3.6", "configs": { "python_version": "3.4.0" + }, + "github_actions_properties": { + "app_runtime": "python", + "app_runtime_version": "3.4.0", + "github_actions_version": "3.6" } }, { @@ -86,6 +126,11 @@ "java_version": "1.8", "java_container": "TOMCAT", "java_container_version": "7.0" + }, + "github_actions_properties": { + "app_runtime": "java", + "app_runtime_version": "1.8, tomcat, 7.0", + "github_actions_version": "8" } }, { @@ -94,6 +139,11 @@ "java_version": "1.8", "java_container": "TOMCAT", "java_container_version": "8.5" + }, + "github_actions_properties": { + "app_runtime": "java", + "app_runtime_version": "1.8, tomcat, 8.5", + "github_actions_version": "8" } }, { @@ -102,6 +152,11 @@ "java_version": "1.8", "java_container": "TOMCAT", "java_container_version": "9.0" + }, + "github_actions_properties": { + "app_runtime": "java", + "app_runtime_version": "1.8, tomcat, 9.0", + "github_actions_version": "8" } }, { @@ -110,6 +165,11 @@ "java_version": "1.8", "java_container": "JAVA", "java_container_version": "SE" + }, + "github_actions_properties": { + "app_runtime": "java", + "app_runtime_version": "1.8, java, se", + "github_actions_version": "8" } }, { @@ -118,6 +178,11 @@ "java_version": "1.7", "java_container": "TOMCAT", "java_container_version": "7.0" + }, + "github_actions_properties": { + "app_runtime": "java", + "app_runtime_version": "7, tomcat, 7.0", + "github_actions_version": "8" } }, { @@ -126,6 +191,11 @@ "java_version": "1.7", "java_container": "TOMCAT", "java_container_version": "8.5" + }, + "github_actions_properties": { + "app_runtime": "java", + "app_runtime_version": "7, tomcat, 8.5", + "github_actions_version": "8" } }, { @@ -134,6 +204,11 @@ "java_version": "1.7", "java_container": "TOMCAT", "java_container_version": "9.0" + }, + "github_actions_properties": { + "app_runtime": "java", + "app_runtime_version": "7, tomcat, 9.0", + "github_actions_version": "8" } }, { @@ -142,6 +217,11 @@ "java_version": "1.7", "java_container": "JAVA", "java_container_version": "SE" + }, + "github_actions_properties": { + "app_runtime": "java", + "app_runtime_version": "7, java, se", + "github_actions_version": "8" } }, { @@ -150,6 +230,11 @@ "java_version": "11", "java_container": "TOMCAT", "java_container_version": "7.0" + }, + "github_actions_properties": { + "app_runtime": "java", + "app_runtime_version": "11, tomcat, 7.0", + "github_actions_version": "11" } }, { @@ -158,6 +243,11 @@ "java_version": "11", "java_container": "TOMCAT", "java_container_version": "8.5" + }, + "github_actions_properties": { + "app_runtime": "java", + "app_runtime_version": "11, tomcat, 8.5", + "github_actions_version": "11" } }, { @@ -166,6 +256,11 @@ "java_version": "11", "java_container": "TOMCAT", "java_container_version": "9.0" + }, + "github_actions_properties": { + "app_runtime": "java", + "app_runtime_version": "11, tomcat, 9.0", + "github_actions_version": "11" } }, { @@ -174,6 +269,11 @@ "java_version": "11", "java_container": "JAVA", "java_container_version": "SE" + }, + "github_actions_properties": { + "app_runtime": "java", + "app_runtime_version": "11, java, se", + "github_actions_version": "11" } } ], @@ -182,12 +282,18 @@ "displayName": "DOTNETCORE|2.1", "configs": { "linux_fx_version": "DOTNETCORE|2.1" + }, + "github_actions_properties": { + "github_actions_version": "2.1.804" } }, { "displayName": "DOTNETCORE|3.1", "configs": { "linux_fx_version": "DOTNETCORE|3.1" + }, + "github_actions_properties": { + "github_actions_version": "3.1.102" } }, { @@ -212,66 +318,99 @@ "displayName": "NODE|12-lts", "configs": { "linux_fx_version": "NODE|12-lts" + }, + "github_actions_properties": { + "github_actions_version": "12.x" } }, { "displayName": "NODE|10-lts", "configs": { "linux_fx_version": "NODE|10-lts" + }, + "github_actions_properties": { + "github_actions_version": "10.x" } }, { "displayName": "NODE|10.1", "configs": { "linux_fx_version": "NODE|10.1" + }, + "github_actions_properties": { + "github_actions_version": "10.1" } }, { "displayName": "NODE|10.6", "configs": { "linux_fx_version": "NODE|10.6" + }, + "github_actions_properties": { + "github_actions_version": "10.6" } }, { "displayName": "NODE|10.14", "configs": { "linux_fx_version": "NODE|10.14" + }, + "github_actions_properties": { + "github_actions_version": "10.14" } }, { "displayName": "JAVA|8-jre8", "configs": { "linux_fx_version": "JAVA|8-jre8" + }, + "github_actions_properties": { + "github_actions_version": "8" } }, { "displayName": "JAVA|11-java11", "configs": { "linux_fx_version": "JAVA|11-java11" + }, + "github_actions_properties": { + "github_actions_version": "11" } }, { "displayName": "TOMCAT|8.5-jre8", "configs": { "linux_fx_version": "TOMCAT|8.5-jre8" + }, + "github_actions_properties": { + "github_actions_version": "8" } }, { "displayName": "TOMCAT|9.0-jre8", "configs": { "linux_fx_version": "TOMCAT|9.0-jre8" + }, + "github_actions_properties": { + "github_actions_version": "8" } }, { "displayName": "TOMCAT|8.5-java11", "configs": { "linux_fx_version": "TOMCAT|8.5-java11" + }, + "github_actions_properties": { + "github_actions_version": "11" } }, { "displayName": "TOMCAT|9.0-java11", "configs": { "linux_fx_version": "TOMCAT|9.0-java11" + }, + "github_actions_properties": { + "github_actions_version": "11" } }, { @@ -314,18 +453,27 @@ "displayName": "PYTHON|3.8", "configs": { "linux_fx_version": "PYTHON|3.8" + }, + "github_actions_properties": { + "github_actions_version": "3.8" } }, { "displayName": "PYTHON|3.7", "configs": { "linux_fx_version": "PYTHON|3.7" + }, + "github_actions_properties": { + "github_actions_version": "3.7" } }, { "displayName": "PYTHON|3.6", "configs": { "linux_fx_version": "PYTHON|3.6" + }, + "github_actions_properties": { + "github_actions_version": "3.6" } }, { diff --git a/src/azure-cli/azure/cli/command_modules/appservice/static_sites.py b/src/azure-cli/azure/cli/command_modules/appservice/static_sites.py index 1cb2775abc1..acb54c3400a 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/static_sites.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/static_sites.py @@ -8,7 +8,7 @@ from knack.util import CLIError from knack.log import get_logger -from .utils import normalize_sku_for_staticapp +from .utils import normalize_sku_for_staticapp, raise_missing_token_suggestion logger = get_logger(__name__) @@ -195,7 +195,7 @@ def create_staticsites(cmd, resource_group_name, name, location, app_location='.', api_location='.', output_location='.github/workflows', tags=None, no_wait=False, sku='Free', login_with_github=False): if not token and not login_with_github: - _raise_missing_token_suggestion() + raise_missing_token_suggestion() elif not token: from ._github_oauth import get_github_access_token scopes = ["admin:repo_hook", "repo", "workflow"] @@ -276,14 +276,6 @@ def _parse_pair(pair, delimiter): return pair[:index], pair[1 + index:] -def _raise_missing_token_suggestion(): - pat_documentation = "https://help.github.com/en/articles/creating-a-personal-access-token-for-the-command-line" - raise CLIError("GitHub access token is required to authenticate to your repositories. " - "If you need to create a Github Personal Access Token, " - "please run with the '--login-with-github' flag or follow " - "the steps found at the following link:\n{0}".format(pat_documentation)) - - def _get_staticsite_location(client, static_site_name, resource_group_name): static_sites = client.list() for static_site in static_sites: 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 a0e4caf91aa..7a7e1afa76b 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/utils.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/utils.py @@ -5,6 +5,7 @@ import time from knack.util import CLIError +from azure.cli.core.azclierror import (RequiredArgumentMissingError) def str2bool(v): @@ -75,3 +76,11 @@ def call(*args, **kwargs): time.sleep(interval_sec) return call return decorate + + +def raise_missing_token_suggestion(): + pat_documentation = "https://help.github.com/en/articles/creating-a-personal-access-token-for-the-command-line" + raise RequiredArgumentMissingError("GitHub access token is required to authenticate to your repositories. " + "If you need to create a Github Personal Access Token, " + "please run with the '--login-with-github' flag or follow " + "the steps found at the following link:\n{0}".format(pat_documentation)) diff --git a/src/azure-cli/setup.py b/src/azure-cli/setup.py index 348e396c749..916710c2478 100644 --- a/src/azure-cli/setup.py +++ b/src/azure-cli/setup.py @@ -139,7 +139,8 @@ 'jsmin~=2.2.2', 'jsondiff==1.2.0', 'packaging~=20.9', - 'PyGithub==1.38', + 'PyGithub~=1.38', + 'PyNaCl~=1.4.0', 'pytz==2019.1', 'scp~=0.13.2', 'semver==2.13.0',