From a18092a4663286cee4e47686c608ee20ed420cd5 Mon Sep 17 00:00:00 2001 From: Silas Strawn Date: Tue, 21 Jun 2022 13:27:40 -0700 Subject: [PATCH] add msi registry support for 'az containerapp create' --- .../azext_containerapp/_constants.py | 2 + .../azext_containerapp/_params.py | 1 + src/containerapp/azext_containerapp/_utils.py | 31 +++++++++- .../azext_containerapp/_validators.py | 20 ++++++- .../azext_containerapp/commands.py | 2 +- src/containerapp/azext_containerapp/custom.py | 59 +++++++++++++++---- 6 files changed, 97 insertions(+), 18 deletions(-) diff --git a/src/containerapp/azext_containerapp/_constants.py b/src/containerapp/azext_containerapp/_constants.py index ef78b3bfaa7..3086ed205ab 100644 --- a/src/containerapp/azext_containerapp/_constants.py +++ b/src/containerapp/azext_containerapp/_constants.py @@ -29,3 +29,5 @@ NAME_INVALID = "Invalid" NAME_ALREADY_EXISTS = "AlreadyExists" + +HELLO_WORLD_IMAGE = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest" diff --git a/src/containerapp/azext_containerapp/_params.py b/src/containerapp/azext_containerapp/_params.py index 694e59288cd..f5ab8286133 100644 --- a/src/containerapp/azext_containerapp/_params.py +++ b/src/containerapp/azext_containerapp/_params.py @@ -94,6 +94,7 @@ def load_arguments(self, _): c.argument('registry_pass', validator=validate_registry_pass, options_list=['--registry-password'], help="The password to log in to container registry. If stored as a secret, value must start with \'secretref:\' followed by the secret name.") c.argument('registry_user', validator=validate_registry_user, options_list=['--registry-username'], help="The username to log in to container registry.") c.argument('secrets', nargs='*', options_list=['--secrets', '-s'], help="A list of secret(s) for the container app. Space-separated values in 'key=value' format.") + c.argument('registry_identity', help="A Managed Identity to authenticate with the registry server instead of username/password. Use a resource ID or 'system' for user-defined and system-defined identities, respectively. The registry must be an ACR. If possible, an 'acrpull' role assignemnt will be created for the identity automatically.") # Ingress with self.argument_context('containerapp', arg_group='Ingress') as c: diff --git a/src/containerapp/azext_containerapp/_utils.py b/src/containerapp/azext_containerapp/_utils.py index 22fcc7f3020..a2f650c69f8 100644 --- a/src/containerapp/azext_containerapp/_utils.py +++ b/src/containerapp/azext_containerapp/_utils.py @@ -12,10 +12,15 @@ from datetime import datetime from dateutil.relativedelta import relativedelta from azure.cli.core.azclierror import (ValidationError, RequiredArgumentMissingError, CLIInternalError, - ResourceNotFoundError, FileOperationError, CLIError) + ResourceNotFoundError, FileOperationError, CLIError, InvalidArgumentValueError) from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.command_modules.appservice.utils import _normalize_location from azure.cli.command_modules.network._client_factory import network_client_factory +from azure.cli.command_modules.role.custom import create_role_assignment +from azure.cli.command_modules.acr.custom import acr_show +from azure.cli.core.commands.client_factory import get_mgmt_service_client +from azure.cli.core.profiles import ResourceType +from azure.mgmt.containerregistry import ContainerRegistryManagementClient from knack.log import get_logger from msrestazure.tools import parse_resource_id, is_valid_resource_id, resource_id @@ -1401,3 +1406,27 @@ def set_managed_identity(cmd, resource_group_name, containerapp_def, system_assi if not isExisting: containerapp_def["identity"]["userAssignedIdentities"][r] = {} + + +def create_acrpull_role_assignment(cmd, registry_server, registry_identity=None, service_principal=None): + if registry_identity: + registry_identity_parsed = parse_resource_id(registry_identity) + registry_identity_name, registry_identity_rg = registry_identity_parsed.get("name"), registry_identity_parsed.get("resource_group") + sp_id = get_mgmt_service_client(cmd.cli_ctx, ResourceType.MGMT_MSI).user_assigned_identities.get(resource_name=registry_identity_name, resource_group_name=registry_identity_rg).principal_id + else: + sp_id = service_principal + + client = get_mgmt_service_client(cmd.cli_ctx, ContainerRegistryManagementClient).registries + acr_id = acr_show(cmd, client, registry_server[: registry_server.rindex(ACR_IMAGE_SUFFIX)]).id + try: + create_role_assignment(cmd, role="acrpull", assignee=sp_id, scope=acr_id) + except Exception as e: + logger.warning(f"Role assignment failed with error message: \"{' '.join(e.args)}\". \n" + f"To add the role assignment manually, please run 'az role assignment create --assignee {sp_id} --scope {acr_id} --role acrpull'. \n" + "You may have to restart the containerapp with 'az containerapp revision restart'.") + + +def is_registry_msi_system(identity): + if identity is None: + return False + return identity.lower() == "system" diff --git a/src/containerapp/azext_containerapp/_validators.py b/src/containerapp/azext_containerapp/_validators.py index 9a70fce3616..1e900235f67 100644 --- a/src/containerapp/azext_containerapp/_validators.py +++ b/src/containerapp/azext_containerapp/_validators.py @@ -4,14 +4,28 @@ # -------------------------------------------------------------------------------------------- # pylint: disable=line-too-long -from azure.cli.core.azclierror import (ValidationError, ResourceNotFoundError) +from azure.cli.core.azclierror import (ValidationError, ResourceNotFoundError, InvalidArgumentValueError, + MutuallyExclusiveArgumentError) +from msrestazure.tools import is_valid_resource_id from ._clients import ContainerAppClient from ._ssh_utils import ping_container_app -from ._utils import safe_get +from ._utils import safe_get, is_registry_msi_system from ._constants import ACR_IMAGE_SUFFIX +# called directly from custom method bc otherwise it disrupts the --environment auto RID functionality +def validate_create(registry_identity, registry_pass, registry_user, registry_server, no_wait): + if registry_identity and (registry_pass or registry_user): + raise MutuallyExclusiveArgumentError("Cannot provide both registry identity and username/password") + if is_registry_msi_system(registry_identity) and no_wait: + raise MutuallyExclusiveArgumentError("--no-wait is not supported with system registry identity") + if registry_identity and not is_valid_resource_id(registry_identity) and not is_registry_msi_system(registry_identity): + raise InvalidArgumentValueError("--registry-identity must be an identity resource ID or 'system'") + if registry_identity and ACR_IMAGE_SUFFIX not in (registry_server or ""): + raise InvalidArgumentValueError("--registry-identity: expected an ACR registry (*.azurecr.io) for --registry-server") + + def _is_number(s): try: float(s) @@ -45,7 +59,7 @@ def validate_cpu(namespace): def validate_managed_env_name_or_id(cmd, namespace): from azure.cli.core.commands.client_factory import get_subscription_id - from msrestazure.tools import is_valid_resource_id, resource_id + from msrestazure.tools import resource_id if namespace.managed_env: if not is_valid_resource_id(namespace.managed_env): diff --git a/src/containerapp/azext_containerapp/commands.py b/src/containerapp/azext_containerapp/commands.py index fe284926d95..614b554fc32 100644 --- a/src/containerapp/azext_containerapp/commands.py +++ b/src/containerapp/azext_containerapp/commands.py @@ -7,7 +7,7 @@ # from azure.cli.core.commands import CliCommandType # from msrestazure.tools import is_valid_resource_id, parse_resource_id from azext_containerapp._client_factory import ex_handler_factory -from ._validators import validate_ssh +from ._validators import validate_ssh, validate_create def transform_containerapp_output(app): diff --git a/src/containerapp/azext_containerapp/custom.py b/src/containerapp/azext_containerapp/custom.py index 58f5f65dd49..18b533ffec8 100644 --- a/src/containerapp/azext_containerapp/custom.py +++ b/src/containerapp/azext_containerapp/custom.py @@ -63,14 +63,16 @@ _update_revision_env_secretrefs, _get_acr_cred, safe_get, await_github_action, repo_url_to_name, validate_container_app_name, _update_weights, get_vnet_location, register_provider_if_needed, generate_randomized_cert_name, _get_name, load_cert_file, check_cert_name_availability, - validate_hostname, patch_new_custom_domain, get_custom_domains, _validate_revision_name, set_managed_identity) + validate_hostname, patch_new_custom_domain, get_custom_domains, _validate_revision_name, set_managed_identity, + create_acrpull_role_assignment, is_registry_msi_system) +from ._validators import validate_create from ._ssh_utils import (SSH_DEFAULT_ENCODING, WebSocketConnection, read_ssh, get_stdin_writer, SSH_CTRL_C_MSG, SSH_BACKUP_ENCODING) from ._constants import (MAXIMUM_SECRET_LENGTH, MICROSOFT_SECRET_SETTING_NAME, FACEBOOK_SECRET_SETTING_NAME, GITHUB_SECRET_SETTING_NAME, GOOGLE_SECRET_SETTING_NAME, TWITTER_SECRET_SETTING_NAME, APPLE_SECRET_SETTING_NAME, CONTAINER_APPS_RP, - NAME_INVALID, NAME_ALREADY_EXISTS, ACR_IMAGE_SUFFIX) + NAME_INVALID, NAME_ALREADY_EXISTS, ACR_IMAGE_SUFFIX, HELLO_WORLD_IMAGE) logger = get_logger(__name__) @@ -318,9 +320,15 @@ def create_containerapp(cmd, no_wait=False, system_assigned=False, disable_warnings=False, - user_assigned=None): + user_assigned=None, + registry_identity=None): register_provider_if_needed(cmd, CONTAINER_APPS_RP) validate_container_app_name(name) + validate_create(registry_identity, registry_pass, registry_user, registry_server, no_wait) + + if registry_identity and not is_registry_msi_system(registry_identity): + logger.info("Creating an acrpull role assignment for the registry identity") + create_acrpull_role_assignment(cmd, registry_server, registry_identity) if yaml: if image or managed_env or min_replicas or max_replicas or target_port or ingress or\ @@ -331,7 +339,7 @@ def create_containerapp(cmd, return create_containerapp_yaml(cmd=cmd, name=name, resource_group_name=resource_group_name, file_name=yaml, no_wait=no_wait) if not image: - image = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest" + image = HELLO_WORLD_IMAGE if managed_env is None: raise RequiredArgumentMissingError('Usage error: --environment is required if not using --yaml') @@ -372,19 +380,22 @@ def create_containerapp(cmd, secrets_def = parse_secret_flags(secrets) registries_def = None - if registry_server is not None: + if registry_server is not None and not is_registry_msi_system(registry_identity): registries_def = RegistryCredentialsModel + registries_def["server"] = registry_server # Infer credentials if not supplied and its azurecr - if registry_user is None or registry_pass is None: + if (registry_user is None or registry_pass is None) and registry_identity is None: registry_user, registry_pass = _infer_acr_credentials(cmd, registry_server, disable_warnings) - registries_def["server"] = registry_server - registries_def["username"] = registry_user + if not registry_identity: + registries_def["username"] = registry_user - if secrets_def is None: - secrets_def = [] - registries_def["passwordSecretRef"] = store_as_secret_and_return_secret_ref(secrets_def, registry_user, registry_server, registry_pass, disable_warnings=disable_warnings) + if secrets_def is None: + secrets_def = [] + registries_def["passwordSecretRef"] = store_as_secret_and_return_secret_ref(secrets_def, registry_user, registry_server, registry_pass, disable_warnings=disable_warnings) + else: + registries_def["identity"] = registry_identity dapr_def = None if dapr_enabled: @@ -440,7 +451,7 @@ def create_containerapp(cmd, container_def = ContainerModel container_def["name"] = container_name if container_name else name - container_def["image"] = image + container_def["image"] = image if not is_registry_msi_system(registry_identity) else HELLO_WORLD_IMAGE if env_vars is not None: container_def["env"] = parse_env_var_flags(env_vars) if startup_command is not None: @@ -465,10 +476,32 @@ def create_containerapp(cmd, containerapp_def["properties"]["template"] = template_def containerapp_def["tags"] = tags + if registry_identity: + if is_registry_msi_system(registry_identity): + set_managed_identity(cmd, resource_group_name, containerapp_def, system_assigned=True) + else: + set_managed_identity(cmd, resource_group_name, containerapp_def, user_assigned=[registry_identity]) + try: r = ContainerAppClient.create_or_update( cmd=cmd, resource_group_name=resource_group_name, name=name, container_app_envelope=containerapp_def, no_wait=no_wait) + if is_registry_msi_system(registry_identity): + while r["properties"]["provisioningState"] == "InProgress": + r = ContainerAppClient.show(cmd, resource_group_name, name) + time.sleep(10) + logger.info("Creating an acrpull role assignment for the system identity") + system_sp = r["identity"]["principalId"] + create_acrpull_role_assignment(cmd, registry_server, registry_identity=None, service_principal=system_sp) + container_def["image"] = image + + registries_def = RegistryCredentialsModel + registries_def["server"] = registry_server + registries_def["identity"] = registry_identity + config_def["registries"] = [registries_def] + + r = ContainerAppClient.create_or_update(cmd=cmd, resource_group_name=resource_group_name, name=name, container_app_envelope=containerapp_def, no_wait=no_wait) + if "properties" in r and "provisioningState" in r["properties"] and r["properties"]["provisioningState"].lower() == "waiting" and not no_wait: not disable_warnings and logger.warning('Containerapp creation in progress. Please monitor the creation using `az containerapp show -n {} -g {}`'.format(name, resource_group_name)) @@ -2276,7 +2309,7 @@ def containerapp_up_logic(cmd, resource_group_name, name, managed_env, image, en # When using repo, image is not passed, so we have to assign it a value (will be overwritten with gh-action) if image is None: - image = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest" + image = HELLO_WORLD_IMAGE if not ca_exists: containerapp_def = None