diff --git a/src/containerapp/azext_containerapp/_clients.py b/src/containerapp/azext_containerapp/_clients.py index 4d525bee181..8184e6d86e2 100644 --- a/src/containerapp/azext_containerapp/_clients.py +++ b/src/containerapp/azext_containerapp/_clients.py @@ -2,8 +2,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- - -from ast import NotEq import json import time import sys @@ -523,3 +521,85 @@ def list_by_resource_group(cls, cmd, resource_group_name, formatter=lambda x: x) env_list.append(formatted) return env_list + +class GitHubActionClient(): + @classmethod + def create_or_update(cls, cmd, resource_group_name, name, github_action_envelope, headers, no_wait=False): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = NEW_API_VERSION + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}/sourcecontrols/current?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "PUT", request_url, body=json.dumps(github_action_envelope), headers=headers) + + if no_wait: + return r.json() + elif r.status_code == 201: + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}/sourcecontrols/current?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + return poll(cmd, request_url, "inprogress") + + return r.json() + + @classmethod + def show(cls, cmd, resource_group_name, name): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = NEW_API_VERSION + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}/sourcecontrols/current?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "GET", request_url) + return r.json() + + #TODO + @classmethod + def delete(cls, cmd, resource_group_name, name, headers, no_wait=False): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = NEW_API_VERSION + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}/sourcecontrols/current?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "DELETE", request_url, headers=headers) + + if no_wait: + return # API doesn't return JSON (it returns no content) + elif r.status_code in [200, 201, 202, 204]: + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}/sourcecontrols/current?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + api_version) + + if r.status_code == 202: + from azure.cli.core.azclierror import ResourceNotFoundError + try: + poll(cmd, request_url, "cancelled") + except ResourceNotFoundError: + pass + logger.warning('Containerapp github action successfully deleted') + return diff --git a/src/containerapp/azext_containerapp/_github_oauth.py b/src/containerapp/azext_containerapp/_github_oauth.py new file mode 100644 index 00000000000..3df73a6b1aa --- /dev/null +++ b/src/containerapp/azext_containerapp/_github_oauth.py @@ -0,0 +1,86 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.azclierror import (ValidationError, CLIInternalError, UnclassifiedUserFault) +from knack.log import get_logger + +logger = get_logger(__name__) + + +''' +Get Github personal access token following Github oauth for command line tools +https://docs.github.com/en/developers/apps/authorizing-oauth-apps#device-flow +''' + + +GITHUB_OAUTH_CLIENT_ID = "8d8e1f6000648c575489" +GITHUB_OAUTH_SCOPES = [ + "admin:repo_hook", + "repo", + "workflow" +] + +def get_github_access_token(cmd, scope_list=None): # pylint: disable=unused-argument + if scope_list: + for scope in scope_list: + if scope not in GITHUB_OAUTH_SCOPES: + raise ValidationError("Requested github oauth scope is invalid") + scope_list = ' '.join(scope_list) + + authorize_url = 'https://github.com/login/device/code' + authorize_url_data = { + 'scope': scope_list, + 'client_id': GITHUB_OAUTH_CLIENT_ID + } + + import requests + import time + from urllib.parse import parse_qs + + try: + response = requests.post(authorize_url, data=authorize_url_data) + parsed_response = parse_qs(response.content.decode('ascii')) + + device_code = parsed_response['device_code'][0] + user_code = parsed_response['user_code'][0] + verification_uri = parsed_response['verification_uri'][0] + interval = int(parsed_response['interval'][0]) + 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) + + timeout = time.time() + expires_in_seconds + logger.warning("Waiting up to '%s' minutes for activation", str(expires_in_seconds // 60)) + + confirmation_url = 'https://github.com/login/oauth/access_token' + confirmation_url_data = { + 'client_id': GITHUB_OAUTH_CLIENT_ID, + 'device_code': device_code, + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code' + } + + pending = True + while pending: + time.sleep(interval) + + if time.time() > timeout: + raise UnclassifiedUserFault('Activation did not happen in time. Please try again') + + confirmation_response = requests.post(confirmation_url, data=confirmation_url_data) + parsed_confirmation_response = parse_qs(confirmation_response.content.decode('ascii')) + + if 'error' in parsed_confirmation_response and parsed_confirmation_response['error'][0]: + if parsed_confirmation_response['error'][0] == 'slow_down': + interval += 5 # if slow_down error is received, 5 seconds is added to minimum polling interval + elif parsed_confirmation_response['error'][0] != 'authorization_pending': + pending = False + + if 'access_token' in parsed_confirmation_response and parsed_confirmation_response['access_token'][0]: + 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)) + + raise UnclassifiedUserFault('Activation did not happen in time. Please try again') \ No newline at end of file diff --git a/src/containerapp/azext_containerapp/_help.py b/src/containerapp/azext_containerapp/_help.py index ac9638014c7..33f196f133e 100644 --- a/src/containerapp/azext_containerapp/_help.py +++ b/src/containerapp/azext_containerapp/_help.py @@ -244,3 +244,50 @@ text: | az containerapp env list -g MyResourceGroup """ +helps['containerapp github-action add'] = """ + type: command + short-summary: Adds GitHub Actions to the Containerapp + examples: + - name: Add GitHub Actions, using Azure Container Registry and personal access token. + text: az containerapp github-action add -g MyResourceGroup -n MyContainerapp --repo-url https://github.com/userid/repo --branch main + --registry-url myregistryurl.azurecr.io + --service-principal-client-id 00000000-0000-0000-0000-00000000 + --service-principal-tenant-id 00000000-0000-0000-0000-00000000 + --service-principal-client-secret ClientSecret + --token MyAccessToken + - name: Add GitHub Actions, using Azure Container Registry and log in to GitHub flow to retrieve personal access token. + text: az containerapp github-action add -g MyResourceGroup -n MyContainerapp --repo-url https://github.com/userid/repo --branch main + --registry-url myregistryurl.azurecr.io + --service-principal-client-id 00000000-0000-0000-0000-00000000 + --service-principal-tenant-id 00000000-0000-0000-0000-00000000 + --service-principal-client-secret ClientSecret + --login-with-github + - name: Add GitHub Actions, using Dockerhub and log in to GitHub flow to retrieve personal access token. + text: az containerapp github-action add -g MyResourceGroup -n MyContainerapp --repo-url https://github.com/userid/repo --branch main + --registry-username MyUsername + --registry-password MyPassword + --service-principal-client-id 00000000-0000-0000-0000-00000000 + --service-principal-tenant-id 00000000-0000-0000-0000-00000000 + --service-principal-client-secret ClientSecret + --login-with-github +""" + +helps['containerapp github-action delete'] = """ + type: command + short-summary: Removes GitHub Actions from the Containerapp + examples: + - name: Removes GitHub Actions, personal access token. + text: az containerapp github-action delete -g MyResourceGroup -n MyContainerapp + --token MyAccessToken + - name: Removes GitHub Actions, using log in to GitHub flow to retrieve personal access token. + text: az containerapp github-action delete -g MyResourceGroup -n MyContainerapp + --login-with-github +""" + +helps['containerapp github-action show'] = """ + type: command + short-summary: Show the GitHub Actions configuration on a Containerapp + examples: + - name: Show the GitHub Actions configuration on a Containerapp + text: az containerapp github-action show -g MyResourceGroup -n MyContainerapp +""" \ No newline at end of file diff --git a/src/containerapp/azext_containerapp/_models.py b/src/containerapp/azext_containerapp/_models.py index f0d068b1bbc..6e8947ee58c 100644 --- a/src/containerapp/azext_containerapp/_models.py +++ b/src/containerapp/azext_containerapp/_models.py @@ -180,3 +180,35 @@ }, "tags": None } + +SourceControl = { + "properties": { + "repoUrl": None, + "branch": None, + "githubActionConfiguration": None # [GitHubActionConfiguration] + } + +} + +GitHubActionConfiguration = { + "registryInfo": None, # [RegistryInfo] + "azureCredentials": None, # [AzureCredentials] + "dockerfilePath": None, # str + "publishType": None, # str + "os": None, # str + "runtimeStack": None, # str + "runtimeVersion": None # str +} + +RegistryInfo = { + "registryUrl": None, # str + "registryUserName": None, # str + "registryPassword": None # str +} + +AzureCredentials = { + "clientId": None, # str + "clientSecret": None, # str + "tenantId": None, #str + "subscriptionId": None #str +} \ No newline at end of file diff --git a/src/containerapp/azext_containerapp/_params.py b/src/containerapp/azext_containerapp/_params.py index c38c32711c4..ac3b640b40e 100644 --- a/src/containerapp/azext_containerapp/_params.py +++ b/src/containerapp/azext_containerapp/_params.py @@ -103,5 +103,22 @@ def load_arguments(self, _): with self.argument_context('containerapp env show') as c: c.argument('name', name_type, help='Name of the managed Environment.') + with self.argument_context('containerapp github-action add') as c: + c.argument('repo_url', help='The GitHub repository to which the workflow file will be added. In the format: https://github.com//') + 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('branch', options_list=['--branch', '-b'], help='The branch of the GitHub repo. Defaults to "master" if not specified.') + c.argument('login_with_github', help='Interactively log in with Github to retrieve the Personal Access Token') + c.argument('registry_url', help='The url of the registry, e.g. myregistry.azurecr.io') + c.argument('registry_username', help='The username of the registry. If using Azure Container Registry, we will try to infer the credentials if not supplied') + c.argument('registry_password', help='The password of the registry. If using Azure Container Registry, we will try to infer the credentials if not supplied') + c.argument('docker_file_path', help='The dockerfile location, e.g. ./Dockerfile') + c.argument('service_principal_client_id', help='The service principal client ID. ') + c.argument('service_principal_client_secret', help='The service principal client secret.') + c.argument('service_principal_tenant_id', help='The service principal tenant ID.') + + with self.argument_context('containerapp github-action delete') as c: + 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('login_with_github', help='Interactively log in with Github to retrieve the Personal Access Token') + with self.argument_context('containerapp revision') as c: c.argument('revision_name', type=str, help='Name of the revision') diff --git a/src/containerapp/azext_containerapp/_utils.py b/src/containerapp/azext_containerapp/_utils.py index 0ed9d21bf43..83b707640f5 100644 --- a/src/containerapp/azext_containerapp/_utils.py +++ b/src/containerapp/azext_containerapp/_utils.py @@ -5,7 +5,8 @@ from distutils.filelist import findall from operator import is_ -from azure.cli.core.azclierror import (ResourceNotFoundError, ValidationError) +from azure.cli.core.azclierror import (ResourceNotFoundError, ValidationError, RequiredArgumentMissingError) + from azure.cli.core.commands.client_factory import get_subscription_id from knack.log import get_logger from msrestazure.tools import parse_resource_id @@ -159,6 +160,12 @@ def parse_list_of_strings(comma_separated_string): comma_separated = comma_separated_string.split(',') return [s.strip() for s in comma_separated] +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)) def _get_default_log_analytics_location(cmd): default_location = "eastus" diff --git a/src/containerapp/azext_containerapp/commands.py b/src/containerapp/azext_containerapp/commands.py index 8fd840ccabd..fed17d21da0 100644 --- a/src/containerapp/azext_containerapp/commands.py +++ b/src/containerapp/azext_containerapp/commands.py @@ -56,6 +56,11 @@ def load_command_table(self, _): # g.custom_command('update', 'update_managed_environment', supports_no_wait=True, exception_handler=ex_handler_factory()) g.custom_command('delete', 'delete_managed_environment', supports_no_wait=True, confirmation=True, exception_handler=ex_handler_factory()) + with self.command_group('containerapp github-action') as g: + g.custom_command('add', 'create_or_update_github_action', exception_handler=ex_handler_factory()) + g.custom_command('show', 'show_github_action', exception_handler=ex_handler_factory()) + g.custom_command('delete', 'delete_github_action', exception_handler=ex_handler_factory()) + with self.command_group('containerapp revision') as g: g.custom_command('activate', 'activate_revision') g.custom_command('deactivate', 'deactivate_revision') diff --git a/src/containerapp/azext_containerapp/custom.py b/src/containerapp/azext_containerapp/custom.py index 3ef91290a5f..bac77b3ab61 100644 --- a/src/containerapp/azext_containerapp/custom.py +++ b/src/containerapp/azext_containerapp/custom.py @@ -5,15 +5,19 @@ from azure.cli.core.azclierror import (RequiredArgumentMissingError, ResourceNotFoundError, ValidationError) from azure.cli.core.commands.client_factory import get_subscription_id +from azure.cli.command_modules.appservice.custom import (_get_acr_cred) from azure.cli.core.util import sdk_no_wait from knack.util import CLIError from knack.log import get_logger +from urllib.parse import urlparse + from msrestazure.tools import parse_resource_id, is_valid_resource_id from msrest.exceptions import DeserializationError from ._client_factory import handle_raw_exception -from ._clients import ManagedEnvironmentClient, ContainerAppClient +from ._clients import ManagedEnvironmentClient, ContainerAppClient, GitHubActionClient from ._sdk_models import * +from ._github_oauth import get_github_access_token from ._models import ( ManagedEnvironment as ManagedEnvironmentModel, VnetConfiguration as VnetConfigurationModel, @@ -27,13 +31,13 @@ Dapr as DaprModel, ContainerResources as ContainerResourcesModel, Scale as ScaleModel, - Container as ContainerModel) + Container as ContainerModel, GitHubActionConfiguration, RegistryInfo as RegistryInfoModel, AzureCredentials as AzureCredentialsModel, SourceControl as SourceControlModel) from ._utils import (_validate_subscription_registered, _get_location_from_resource_group, _ensure_location_allowed, parse_secret_flags, store_as_secret_and_return_secret_ref, parse_list_of_strings, parse_env_var_flags, _generate_log_analytics_if_not_provided, _get_existing_secrets, _convert_object_from_snake_to_camel_case, _object_to_dict, _add_or_update_secrets, _remove_additional_attributes, _remove_readonly_attributes, _add_or_update_env_vars, _add_or_update_tags, update_nested_dictionary, _update_traffic_Weights, - _get_app_from_revision) + _get_app_from_revision, raise_missing_token_suggestion) logger = get_logger(__name__) @@ -908,6 +912,195 @@ def delete_managed_environment(cmd, name, resource_group_name, no_wait=False): handle_raw_exception(e) +def create_or_update_github_action(cmd, + name, + resource_group_name, + repo_url, + registry_url=None, + registry_username=None, + registry_password=None, + branch=None, + token=None, + login_with_github=False, + docker_file_path=None, + service_principal_client_id=None, + service_principal_client_secret=None, + service_principal_tenant_id=None): + 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") + + try: + # Verify github repo + from github import Github, GithubException + from github.GithubException import BadCredentialsException, UnknownObjectException + + repo = None + repo = repo_url.split('/') + if len(repo) >= 2: + repo = '/'.join(repo[-2:]) + + if repo: + g = Github(token) + github_repo = None + try: + github_repo = g.get_repo(repo) + if not github_repo.permissions.push or not github_repo.permissions.maintain: + raise CLIError("The token does not have appropriate access rights to repository {}.".format(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) + except CLIError as clierror: + raise clierror + except Exception as ex: + # If exception due to github package missing, etc just continue without validating the repo and rely on api validation + pass + + source_control_info = None + + try: + #source_control_info = client.get_source_control_info(resource_group_name, name).properties + source_control_info = GitHubActionClient.show(cmd=cmd, resource_group_name=resource_group_name, name=name) + + except Exception as ex: + if not service_principal_client_id or not service_principal_client_secret or not service_principal_tenant_id: + raise RequiredArgumentMissingError('Service principal client ID, secret and tenant ID are required to add github actions for the first time. Please create one using the command \"az ad sp create-for-rbac --name \{name\} --role contributor --scopes /subscriptions/\{subscription\}/resourceGroups/\{resourceGroup\} --sdk-auth\"') + source_control_info = SourceControlModel + + source_control_info["properties"]["repoUrl"] = repo_url + + if branch: + source_control_info["properties"]["branch"] = branch + if not source_control_info["properties"]["branch"]: + source_control_info["properties"]["branch"] = "master" + + azure_credentials = None + + if service_principal_client_id or service_principal_client_secret or service_principal_tenant_id: + azure_credentials = AzureCredentialsModel + azure_credentials["clientId"] = service_principal_client_id + azure_credentials["clientSecret"] = service_principal_client_secret + azure_credentials["tenantId"] = service_principal_tenant_id + azure_credentials["subscriptionId"] = get_subscription_id(cmd.cli_ctx) + + # Registry + if not registry_username or not registry_password: + # If registry is Azure Container Registry, we can try inferring credentials + if not registry_url or '.azurecr.io' not in registry_url: + raise RequiredArgumentMissingError('Registry url is required if using Azure Container Registry, otherwise Registry username and password are required if using Dockerhub') + logger.warning('No credential was provided to access Azure Container Registry. Trying to look up...') + parsed = urlparse(registry_url) + registry_name = (parsed.netloc if parsed.scheme else parsed.path).split('.')[0] + + try: + registry_username, registry_password = _get_acr_cred(cmd.cli_ctx, registry_name) + except Exception as ex: + raise RequiredArgumentMissingError('Failed to retrieve credentials for container registry. Please provide the registry username and password') + + registry_info = RegistryInfoModel + registry_info["registryUrl"] = registry_url + registry_info["registryUserName"] = registry_username + registry_info["registryPassword"] = registry_password + + github_action_configuration = GitHubActionConfiguration + github_action_configuration["registryInfo"] = registry_info + github_action_configuration["azureCredentials"] = azure_credentials + github_action_configuration["dockerfilePath"] = docker_file_path + + source_control_info["properties"]["githubActionConfiguration"] = github_action_configuration + + headers = ["x-ms-github-auxiliary={}".format(token)] + + try: + r = GitHubActionClient.create_or_update(cmd = cmd, resource_group_name=resource_group_name, name=name, github_action_envelope=source_control_info, headers = headers) + return r + except Exception as e: + handle_raw_exception(e) + + +def show_github_action(cmd, name, resource_group_name): + try: + return GitHubActionClient.show(cmd=cmd, resource_group_name=resource_group_name, name=name) + except Exception as e: + handle_raw_exception(e) + + +def delete_github_action(cmd, name, resource_group_name, token=None, login_with_github=False): + # Check if there is an existing source control to delete + try: + github_action_config = GitHubActionClient.show(cmd=cmd, resource_group_name=resource_group_name, name=name) + except Exception as e: + handle_raw_exception(e) + + repo_url = github_action_config["properties"]["repoUrl"] + + 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") + + # Check if PAT can access repo + try: + # Verify github repo + from github import Github, GithubException + from github.GithubException import BadCredentialsException, UnknownObjectException + + repo = None + repo = repo_url.split('/') + if len(repo) >= 2: + repo = '/'.join(repo[-2:]) + + if repo: + g = Github(token) + github_repo = None + try: + github_repo = g.get_repo(repo) + if not github_repo.permissions.push or not github_repo.permissions.maintain: + raise CLIError("The token does not have appropriate access rights to repository {}.".format(repo)) + 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) + except CLIError as clierror: + raise clierror + except Exception as ex: + # If exception due to github package missing, etc just continue without validating the repo and rely on api validation + pass + + headers = ["x-ms-github-auxiliary={}".format(token)] + + try: + return GitHubActionClient.delete(cmd=cmd, resource_group_name=resource_group_name, name=name, headers=headers) + except Exception as e: + handle_raw_exception(e) + + def list_revisions(cmd, name, resource_group_name): try: return ContainerAppClient.list_revisions(cmd=cmd, resource_group_name=resource_group_name, name=name)