diff --git a/src/containerapp/HISTORY.rst b/src/containerapp/HISTORY.rst index 8d5cd41c41e..b58841c00cd 100644 --- a/src/containerapp/HISTORY.rst +++ b/src/containerapp/HISTORY.rst @@ -2,8 +2,15 @@ Release History =============== -Upcoming -+++++++ +0.3.22 +++++++ +* BREAKING CHANGE: 'az containerapp env certificate list' returns [] if certificate not found, instead of raising an error. +* Added 'az containerapp env certificate create' to create managed certificate in a container app environment +* Added 'az containerapp hostname add' to add hostname to a container app without binding +* 'az containerapp env certificate delete': add support for managed certificate deletion +* 'az containerapp env certificate list': add optional parameters --managed-certificates-only and --private-key-certificates-only to list certificates by type +* 'az containerapp hostname bind': change --thumbprint to an optional parameter and add optional parameter --validation-method to support managed certificate bindings +* 'az containerapp ssl upload': log messages to indicate which step is in progress * Fix the 'TypeError: 'NoneType' object does not support item assignment' error obtained while running the CLI command 'az containerapp dapr enable' 0.3.21 diff --git a/src/containerapp/azext_containerapp/_client_factory.py b/src/containerapp/azext_containerapp/_client_factory.py index 4e8ad424138..d0852ce7e62 100644 --- a/src/containerapp/azext_containerapp/_client_factory.py +++ b/src/containerapp/azext_containerapp/_client_factory.py @@ -54,6 +54,33 @@ def handle_raw_exception(e): raise e +def handle_non_404_exception(e): + import json + + stringErr = str(e) + + if "{" in stringErr and "}" in stringErr: + jsonError = stringErr[stringErr.index("{"):stringErr.rindex("}") + 1] + jsonError = json.loads(jsonError) + + if 'error' in jsonError: + jsonError = jsonError['error'] + + if 'code' in jsonError and 'message' in jsonError: + code = jsonError['code'] + message = jsonError['message'] + if code != "ResourceNotFound": + raise CLIInternalError('({}) {}'.format(code, message)) + return jsonError + elif "Message" in jsonError: + message = jsonError["Message"] + raise CLIInternalError(message) + elif "message" in jsonError: + message = jsonError["message"] + raise CLIInternalError(message) + raise e + + def providers_client_factory(cli_ctx, subscription_id=None): return get_mgmt_service_client(cli_ctx, ResourceType.MGMT_RESOURCE_RESOURCES, subscription_id=subscription_id).providers diff --git a/src/containerapp/azext_containerapp/_clients.py b/src/containerapp/azext_containerapp/_clients.py index e40fa59ddb3..b0b9f25540c 100644 --- a/src/containerapp/azext_containerapp/_clients.py +++ b/src/containerapp/azext_containerapp/_clients.py @@ -16,8 +16,11 @@ PREVIEW_API_VERSION = "2022-06-01-preview" CURRENT_API_VERSION = PREVIEW_API_VERSION +MANAGED_CERTS_API_VERSION = '2022-11-01-preview' POLLING_TIMEOUT = 600 # how many seconds before exiting POLLING_SECONDS = 2 # how many seconds between requests +POLLING_TIMEOUT_FOR_MANAGED_CERTIFICATE = 1500 # how many seconds before exiting +POLLING_INTERVAL_FOR_MANAGED_CERTIFICATE = 4 # how many seconds between requests class PollingAnimation(): @@ -617,6 +620,23 @@ def show_certificate(cls, cmd, resource_group_name, name, certificate_name): r = send_raw_request(cmd.cli_ctx, "GET", request_url, body=None) return r.json() + @classmethod + def show_managed_certificate(cls, cmd, resource_group_name, name, certificate_name): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = MANAGED_CERTS_API_VERSION + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/managedEnvironments/{}/managedCertificates/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + certificate_name, + api_version) + + r = send_raw_request(cmd.cli_ctx, "GET", request_url, body=None) + return r.json() + @classmethod def list_certificates(cls, cmd, resource_group_name, name, formatter=lambda x: x): certs_list = [] @@ -639,6 +659,28 @@ def list_certificates(cls, cmd, resource_group_name, name, formatter=lambda x: x certs_list.append(formatted) return certs_list + @classmethod + def list_managed_certificates(cls, cmd, resource_group_name, name, formatter=lambda x: x): + certs_list = [] + + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = MANAGED_CERTS_API_VERSION + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/managedEnvironments/{}/managedCertificates?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, body=None) + j = r.json() + for cert in j["value"]: + formatted = formatter(cert) + certs_list.append(formatted) + return certs_list + @classmethod def create_or_update_certificate(cls, cmd, resource_group_name, name, certificate_name, certificate): management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager @@ -656,6 +698,51 @@ def create_or_update_certificate(cls, cmd, resource_group_name, name, certificat r = send_raw_request(cmd.cli_ctx, "PUT", request_url, body=json.dumps(certificate)) return r.json() + @classmethod + def create_or_update_managed_certificate(cls, cmd, resource_group_name, name, certificate_name, certificate_envelop, no_wait=False, is_TXT=False): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = MANAGED_CERTS_API_VERSION + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/managedEnvironments/{}/managedCertificates/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + certificate_name, + api_version) + r = send_raw_request(cmd.cli_ctx, "PUT", request_url, body=json.dumps(certificate_envelop)) + + if no_wait and not is_TXT: + return r.json() + elif r.status_code == 201: + try: + start = time.time() + end = time.time() + POLLING_TIMEOUT_FOR_MANAGED_CERTIFICATE + animation = PollingAnimation() + animation.tick() + r = send_raw_request(cmd.cli_ctx, "GET", request_url) + message_logged = False + while r.status_code in [200, 201] and start < end: + time.sleep(POLLING_INTERVAL_FOR_MANAGED_CERTIFICATE) + animation.tick() + r = send_raw_request(cmd.cli_ctx, "GET", request_url) + r2 = r.json() + if is_TXT and not message_logged and "properties" in r2 and "validationToken" in r2["properties"]: + logger.warning('\nPlease copy the token below for TXT record and enter it with your domain provider:\n%s\n', r2["properties"]["validationToken"]) + message_logged = True + if no_wait: + break + if "properties" not in r2 or "provisioningState" not in r2["properties"] or r2["properties"]["provisioningState"].lower() in ["succeeded", "failed", "canceled"]: + break + start = time.time() + animation.flush() + return r.json() + except Exception as e: # pylint: disable=broad-except + animation.flush() + raise e + return r.json() + @classmethod def delete_certificate(cls, cmd, resource_group_name, name, certificate_name): management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager @@ -672,6 +759,22 @@ def delete_certificate(cls, cmd, resource_group_name, name, certificate_name): return send_raw_request(cmd.cli_ctx, "DELETE", request_url, body=None) + @classmethod + def delete_managed_certificate(cls, cmd, resource_group_name, name, certificate_name): + management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager + api_version = MANAGED_CERTS_API_VERSION + sub_id = get_subscription_id(cmd.cli_ctx) + url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/managedEnvironments/{}/managedCertificates/{}?api-version={}" + request_url = url_fmt.format( + management_hostname.strip('/'), + sub_id, + resource_group_name, + name, + certificate_name, + api_version) + + return send_raw_request(cmd.cli_ctx, "DELETE", request_url, body=None) + @classmethod def check_name_availability(cls, cmd, resource_group_name, name, name_availability_request): management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager diff --git a/src/containerapp/azext_containerapp/_constants.py b/src/containerapp/azext_containerapp/_constants.py index 38d57684f45..3f74f9b07b6 100644 --- a/src/containerapp/azext_containerapp/_constants.py +++ b/src/containerapp/azext_containerapp/_constants.py @@ -14,6 +14,13 @@ LOG_ANALYTICS_RP = "Microsoft.OperationalInsights" CONTAINER_APPS_RP = "Microsoft.App" +MANAGED_CERTIFICATE_RT = "managedCertificates" +PRIVATE_CERTIFICATE_RT = "certificates" + +PENDING_STATUS = "Pending" +SUCCEEDED_STATUS = "Succeeded" +UPDATING_STATUS = "Updating" + MICROSOFT_SECRET_SETTING_NAME = "microsoft-provider-authentication-secret" FACEBOOK_SECRET_SETTING_NAME = "facebook-provider-authentication-secret" GITHUB_SECRET_SETTING_NAME = "github-provider-authentication-secret" diff --git a/src/containerapp/azext_containerapp/_help.py b/src/containerapp/azext_containerapp/_help.py index bfe092fa3e5..9a2f8ac4c33 100644 --- a/src/containerapp/azext_containerapp/_help.py +++ b/src/containerapp/azext_containerapp/_help.py @@ -500,6 +500,15 @@ short-summary: Commands to manage certificates for the Container Apps environment. """ +helps['containerapp env certificate create'] = """ + type: command + short-summary: Create a managed certificate. + examples: + - name: Create a managed certificate. + text: | + az containerapp env certificate create -g MyResourceGroup --name MyEnvironment --certificate-name MyCertificate --hostname MyHostname --validation-method CNAME +""" + helps['containerapp env certificate list'] = """ type: command short-summary: List certificates for an environment. @@ -507,7 +516,7 @@ - name: List certificates for an environment. text: | az containerapp env certificate list -g MyResourceGroup --name MyEnvironment - - name: List certificates by certificate id. + - name: Show a certificate by certificate id. text: | az containerapp env certificate list -g MyResourceGroup --name MyEnvironment --certificate MyCertificateId - name: List certificates by certificate name. @@ -516,6 +525,12 @@ - name: List certificates by certificate thumbprint. text: | az containerapp env certificate list -g MyResourceGroup --name MyEnvironment --thumbprint MyCertificateThumbprint + - name: List managed certificates for an environment. + text: | + az containerapp env certificate list -g MyResourceGroup --name MyEnvironment --managed-certificates-only + - name: List private key certificates for an environment. + text: | + az containerapp env certificate list -g MyResourceGroup --name MyEnvironment --private-key-certificates-only """ helps['containerapp env certificate upload'] = """ @@ -540,7 +555,7 @@ - name: Delete a certificate from the Container Apps environment by certificate id text: | az containerapp env certificate delete -g MyResourceGroup --name MyEnvironment --certificate MyCertificateId - - name: Delete a certificate from the Container Apps environment by certificate thumbprint + - name: Delete all certificates that have a matching thumbprint from the Container Apps environment text: | az containerapp env certificate delete -g MyResourceGroup --name MyEnvironment --thumbprint MyCertificateThumbprint """ @@ -884,13 +899,25 @@ short-summary: Commands to manage hostnames of a container app. """ +helps['containerapp hostname add'] = """ + type: command + short-summary: Add the hostname to a container app without binding. + examples: + - name: Add hostname without binding. + text: | + az containerapp hostname add -n MyContainerapp -g MyResourceGroup --hostname MyHostname --location MyLocation +""" + helps['containerapp hostname bind'] = """ type: command - short-summary: Add or update the hostname and binding with an existing certificate. + short-summary: Add or update the hostname and binding with a certificate. examples: - - name: Add or update hostname and binding. + - name: Add or update hostname and binding with a provided certificate. text: | az containerapp hostname bind -n MyContainerapp -g MyResourceGroup --hostname MyHostname --certificate MyCertificateId + - name: Look for or create a managed certificate and bind with the hostname if no certificate or thumbprint is provided. + text: | + az containerapp hostname bind -n MyContainerapp -g MyResourceGroup --hostname MyHostname """ helps['containerapp hostname delete'] = """ diff --git a/src/containerapp/azext_containerapp/_models.py b/src/containerapp/azext_containerapp/_models.py index d6d3a45d6c6..b259476f663 100644 --- a/src/containerapp/azext_containerapp/_models.py +++ b/src/containerapp/azext_containerapp/_models.py @@ -279,3 +279,11 @@ "accessMode": None, "shareName": None } + +ManagedCertificateEnvelop = { + "location": None, # str + "properties": { + "subjectName": None, # str + "validationMethod": None # str + } +} diff --git a/src/containerapp/azext_containerapp/_params.py b/src/containerapp/azext_containerapp/_params.py index 70251a27329..5018d62ca7a 100644 --- a/src/containerapp/azext_containerapp/_params.py +++ b/src/containerapp/azext_containerapp/_params.py @@ -176,6 +176,12 @@ def load_arguments(self, _): with self.argument_context('containerapp env show') as c: c.argument('name', name_type, help='Name of the Container Apps Environment.') + with self.argument_context('containerapp env certificate create') as c: + c.argument('hostname', options_list=['--hostname'], help='The custom domain name.') + c.argument('certificate_name', options_list=['--certificate-name', '-c'], help='Name of the managed certificate which should be unique within the Container Apps environment.') + c.argument('location', get_location_type(self.cli_ctx), help='Location of the managed certificate which can be different from the location of the Container Apps environment.') + c.argument('validation_method', options_list=['--validation-method', '-v'], help='Validation method of custom domain ownership.') + with self.argument_context('containerapp env certificate upload') as c: c.argument('certificate_file', options_list=['--certificate-file', '-f'], help='The filepath of the .pfx or .pem file') c.argument('certificate_name', options_list=['--certificate-name', '-c'], help='Name of the certificate which should be unique within the Container Apps environment.') @@ -186,6 +192,8 @@ def load_arguments(self, _): c.argument('name', id_part=None) c.argument('certificate', options_list=['--certificate', '-c'], help='Name or resource id of the certificate.') c.argument('thumbprint', options_list=['--thumbprint', '-t'], help='Thumbprint of the certificate.') + c.argument('managed_certificates_only', options_list=['--managed-certificates-only', '-m'], help='List managed certificates only.') + c.argument('private_key_certificates_only', options_list=['--private-key-certificates-only', '-p'], help='List private-key certificates only.') with self.argument_context('containerapp env certificate delete') as c: c.argument('certificate', options_list=['--certificate', '-c'], help='Name or resource id of the certificate.') @@ -366,6 +374,11 @@ def load_arguments(self, _): c.argument('thumbprint', options_list=['--thumbprint', '-t'], help='Thumbprint of the certificate.') c.argument('certificate', options_list=['--certificate', '-c'], help='Name or resource id of the certificate.') c.argument('environment', options_list=['--environment', '-e'], help='Name or resource id of the Container App environment.') + c.argument('validation_method', options_list=['--validation-method', '-v'], help='Validation method of custom domain ownership.') + + with self.argument_context('containerapp hostname add') as c: + c.argument('hostname', help='The custom domain name.') + c.argument('location', arg_type=get_location_type(self.cli_ctx)) with self.argument_context('containerapp hostname list') as c: c.argument('name', id_part=None) diff --git a/src/containerapp/azext_containerapp/_utils.py b/src/containerapp/azext_containerapp/_utils.py index c70de62a93d..a6195a8ba0d 100644 --- a/src/containerapp/azext_containerapp/_utils.py +++ b/src/containerapp/azext_containerapp/_utils.py @@ -29,8 +29,8 @@ from ._client_factory import handle_raw_exception, providers_client_factory, cf_resource_groups, log_analytics_client_factory, log_analytics_shared_key_client_factory from ._constants import (MAXIMUM_CONTAINER_APP_NAME_LENGTH, SHORT_POLLING_INTERVAL_SECS, LONG_POLLING_INTERVAL_SECS, LOG_ANALYTICS_RP, CONTAINER_APPS_RP, CHECK_CERTIFICATE_NAME_AVAILABILITY_TYPE, ACR_IMAGE_SUFFIX, - LOGS_STRING) -from ._models import (ContainerAppCustomDomainEnvelope as ContainerAppCustomDomainEnvelopeModel) + LOGS_STRING, PENDING_STATUS, SUCCEEDED_STATUS, UPDATING_STATUS) +from ._models import (ContainerAppCustomDomainEnvelope as ContainerAppCustomDomainEnvelopeModel, ManagedCertificateEnvelop as ManagedCertificateEnvelopModel) logger = get_logger(__name__) @@ -1097,6 +1097,15 @@ def generate_randomized_cert_name(thumbprint, prefix, initial="rg"): return cert_name.lower() +def generate_randomized_managed_cert_name(hostname, env_name): + from random import randint + cert_name = "mc-{}-{}-{:04}".format(env_name[:14], hostname[:16].lower(), randint(0, 9999)) + for c in cert_name: + if not (c.isalnum() or c == '-'): + cert_name = cert_name.replace(c, '-') + return cert_name.lower() + + def _set_webapp_up_default_args(cmd, resource_group_name, location, name, registry_server): from azure.cli.core.util import ConfiguredDefaultSetter with ConfiguredDefaultSetter(cmd.cli_ctx.config, True): @@ -1367,6 +1376,29 @@ def check_cert_name_availability(cmd, resource_group_name, name, cert_name): return r +def prepare_managed_certificate_envelop(cmd, name, resource_group_name, hostname, validation_method, location=None): + certificate_envelop = ManagedCertificateEnvelopModel + certificate_envelop["location"] = location + certificate_envelop["properties"]["subjectName"] = hostname + certificate_envelop["properties"]["validationMethod"] = validation_method + if not location: + try: + managed_env = ManagedEnvironmentClient.show(cmd, resource_group_name, name) + certificate_envelop["location"] = managed_env["location"] + except Exception as e: + handle_raw_exception(e) + return certificate_envelop + + +def check_managed_cert_name_availability(cmd, resource_group_name, name, cert_name): + try: + certs = ManagedEnvironmentClient.list_managed_certificates(cmd, resource_group_name, name) + r = any(cert["name"] == cert_name and cert["properties"]["provisioningState"] in [PENDING_STATUS, SUCCEEDED_STATUS, UPDATING_STATUS] for cert in certs) + except CLIError as e: + handle_raw_exception(e) + return not r + + def validate_hostname(cmd, resource_group_name, name, hostname): passed = False message = None @@ -1577,3 +1609,15 @@ def _azure_monitor_quickstart(cmd, name, resource_group_name, storage_account, l logger.warning("Azure Monitor diagnastic settings created successfully.") except Exception as ex: handle_raw_exception(ex) + + +def certificate_location_matches(certificate_object, location=None): + return certificate_object["location"] == location or not location + + +def certificate_thumbprint_matches(certificate_object, thumbprint=None): + return certificate_object["properties"]["thumbprint"] == thumbprint or not thumbprint + + +def certificate_matches(certificate_object, location=None, thumbprint=None): + return certificate_location_matches(certificate_object, location) and certificate_thumbprint_matches(certificate_object, thumbprint) diff --git a/src/containerapp/azext_containerapp/_validators.py b/src/containerapp/azext_containerapp/_validators.py index 815e339ca10..a5c92c527e5 100644 --- a/src/containerapp/azext_containerapp/_validators.py +++ b/src/containerapp/azext_containerapp/_validators.py @@ -4,11 +4,11 @@ # -------------------------------------------------------------------------------------------- # pylint: disable=line-too-long +import re from azure.cli.core.azclierror import (ValidationError, ResourceNotFoundError, InvalidArgumentValueError, MutuallyExclusiveArgumentError) from msrestazure.tools import is_valid_resource_id from knack.log import get_logger -import re from ._clients import ContainerAppClient from ._ssh_utils import ping_container_app diff --git a/src/containerapp/azext_containerapp/commands.py b/src/containerapp/azext_containerapp/commands.py index f50332c28b1..2c494ab0585 100644 --- a/src/containerapp/azext_containerapp/commands.py +++ b/src/containerapp/azext_containerapp/commands.py @@ -77,6 +77,7 @@ def load_command_table(self, _): g.custom_command('remove', 'remove_dapr_component') with self.command_group('containerapp env certificate') as g: + g.custom_command('create', 'create_managed_certificate') g.custom_command('list', 'list_certificates') g.custom_command('upload', 'upload_certificate') g.custom_command('delete', 'delete_certificate', confirmation=True, exception_handler=ex_handler_factory()) @@ -179,6 +180,7 @@ def load_command_table(self, _): g.custom_command('upload', 'upload_ssl', exception_handler=ex_handler_factory()) with self.command_group('containerapp hostname') as g: + g.custom_command('add', 'add_hostname', exception_handler=ex_handler_factory()) g.custom_command('bind', 'bind_hostname', exception_handler=ex_handler_factory()) g.custom_command('list', 'list_hostname') g.custom_command('delete', 'delete_hostname', confirmation=True, exception_handler=ex_handler_factory()) diff --git a/src/containerapp/azext_containerapp/custom.py b/src/containerapp/azext_containerapp/custom.py index 30c9ab45c9e..60d5b79aa93 100644 --- a/src/containerapp/azext_containerapp/custom.py +++ b/src/containerapp/azext_containerapp/custom.py @@ -23,12 +23,12 @@ from azure.cli.core.util import open_page_in_browser from azure.cli.command_modules.appservice.utils import _normalize_location from knack.log import get_logger -from knack.prompting import prompt_y_n +from knack.prompting import prompt_y_n, prompt as prompt_str from msrestazure.tools import parse_resource_id, is_valid_resource_id from msrest.exceptions import DeserializationError -from ._client_factory import handle_raw_exception +from ._client_factory import handle_raw_exception, handle_non_404_exception from ._clients import ManagedEnvironmentClient, ContainerAppClient, GitHubActionClient, DaprComponentClient, StorageClient, AuthClient from ._github_oauth import get_github_access_token from ._models import ( @@ -69,13 +69,15 @@ validate_hostname, patch_new_custom_domain, get_custom_domains, _validate_revision_name, set_managed_identity, create_acrpull_role_assignment, is_registry_msi_system, clean_null_values, _populate_secret_values, validate_environment_location, safe_set, parse_metadata_flags, parse_auth_flags, _azure_monitor_quickstart, - set_ip_restrictions) + set_ip_restrictions, certificate_location_matches, certificate_matches, generate_randomized_managed_cert_name, + check_managed_cert_name_availability, prepare_managed_certificate_envelop) from ._validators import validate_create, validate_revision_suffix 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, HELLO_WORLD_IMAGE, LOG_TYPE_SYSTEM, LOG_TYPE_CONSOLE) + NAME_INVALID, NAME_ALREADY_EXISTS, ACR_IMAGE_SUFFIX, HELLO_WORLD_IMAGE, LOG_TYPE_SYSTEM, LOG_TYPE_CONSOLE, + MANAGED_CERTIFICATE_RT, PRIVATE_CERTIFICATE_RT, PENDING_STATUS, SUCCEEDED_STATUS) logger = get_logger(__name__) @@ -2030,7 +2032,7 @@ def show_ip_restrictions(cmd, name, resource_group_name): except Exception as e: raise ValidationError("Ingress must be enabled to list ip restrictions. Try running `az containerapp ingress -h` for more info.") from e return safe_get(containerapp_def, "properties", "configuration", "ingress", "ipSecurityRestrictions", default=[]) - except Exception as e: + except: return [] @@ -2735,32 +2737,73 @@ def containerapp_up_logic(cmd, resource_group_name, name, managed_env, image, en return create_containerapp(cmd=cmd, name=name, resource_group_name=resource_group_name, managed_env=managed_env, image=image, env_vars=env_vars, ingress=ingress, target_port=target_port, registry_server=registry_server, registry_user=registry_user, registry_pass=registry_pass) -def list_certificates(cmd, name, resource_group_name, location=None, certificate=None, thumbprint=None): - _validate_subscription_registered(cmd, CONTAINER_APPS_RP) +def create_managed_certificate(cmd, name, resource_group_name, hostname, validation_method, certificate_name=None, location=None): + if certificate_name and not check_managed_cert_name_availability(cmd, resource_group_name, name, certificate_name): + raise ValidationError(f"Certificate name '{certificate_name}' is not available.") + cert_name = certificate_name + while not cert_name: + cert_name = generate_randomized_managed_cert_name(hostname, resource_group_name) + if not check_managed_cert_name_availability(cmd, resource_group_name, name, certificate_name): + cert_name = None + certificate_envelop = prepare_managed_certificate_envelop(cmd, name, resource_group_name, hostname, validation_method, location) + try: + r = ManagedEnvironmentClient.create_or_update_managed_certificate(cmd, resource_group_name, name, cert_name, certificate_envelop, True, validation_method == 'TXT') + return r + except Exception as e: + handle_raw_exception(e) - def location_match(c): - return c["location"] == location or not location - def thumbprint_match(c): - return c["properties"]["thumbprint"] == thumbprint or not thumbprint +def list_certificates(cmd, name, resource_group_name, location=None, certificate=None, thumbprint=None, managed_certificates_only=False, private_key_certificates_only=False): + _validate_subscription_registered(cmd, CONTAINER_APPS_RP) + if managed_certificates_only and private_key_certificates_only: + raise MutuallyExclusiveArgumentError("Use either '--managed-certificates-only' or '--private-key-certificates-only'.") + if managed_certificates_only and thumbprint: + raise MutuallyExclusiveArgumentError("'--thumbprint' not supported for managed certificates.") + + if certificate and is_valid_resource_id(certificate): + certificate_name = parse_resource_id(certificate)["resource_name"] + certificate_type = parse_resource_id(certificate)["resource_type"] + else: + certificate_name = certificate + certificate_type = PRIVATE_CERTIFICATE_RT if private_key_certificates_only or thumbprint else (MANAGED_CERTIFICATE_RT if managed_certificates_only else None) - def both_match(c): - return location_match(c) and thumbprint_match(c) + if certificate_type == MANAGED_CERTIFICATE_RT: + return get_managed_certificates(cmd, name, resource_group_name, certificate_name, location) + if certificate_type == PRIVATE_CERTIFICATE_RT: + return get_private_certificates(cmd, name, resource_group_name, certificate_name, thumbprint, location) + managed_certs = get_managed_certificates(cmd, name, resource_group_name, certificate_name, location) + private_certs = get_private_certificates(cmd, name, resource_group_name, certificate_name, thumbprint, location) + return managed_certs + private_certs - if certificate: - if is_valid_resource_id(certificate): - certificate_name = parse_resource_id(certificate)["resource_name"] - else: - certificate_name = certificate + +def get_private_certificates(cmd, name, resource_group_name, certificate_name=None, thumbprint=None, location=None): + if certificate_name: try: r = ManagedEnvironmentClient.show_certificate(cmd, resource_group_name, name, certificate_name) - return [r] if both_match(r) else [] + return [r] if certificate_matches(r, location, thumbprint) else [] except Exception as e: - handle_raw_exception(e) + handle_non_404_exception(e) + return [] else: try: r = ManagedEnvironmentClient.list_certificates(cmd, resource_group_name, name) - return list(filter(both_match, r)) + return list(filter(lambda c: certificate_matches(c, location, thumbprint), r)) + except Exception as e: + handle_raw_exception(e) + + +def get_managed_certificates(cmd, name, resource_group_name, certificate_name=None, location=None): + if certificate_name: + try: + r = ManagedEnvironmentClient.show_managed_certificate(cmd, resource_group_name, name, certificate_name) + return [r] if certificate_location_matches(r, location) else [] + except Exception as e: + handle_non_404_exception(e) + return [] + else: + try: + r = ManagedEnvironmentClient.list_managed_certificates(cmd, resource_group_name, name) + return list(filter(lambda c: certificate_location_matches(c, location), r)) except Exception as e: handle_raw_exception(e) @@ -2819,11 +2862,42 @@ def delete_certificate(cmd, resource_group_name, name, location=None, certificat if not certificate and not thumbprint: raise RequiredArgumentMissingError('Please specify at least one of parameters: --certificate and --thumbprint') - certs = list_certificates(cmd, name, resource_group_name, location, certificate, thumbprint) - for cert in certs: + + cert_type = None + cert_name = certificate + if certificate and is_valid_resource_id(certificate): + cert_type = parse_resource_id(certificate)["resource_type"] + cert_name = parse_resource_id(certificate)["resource_name"] + if thumbprint: + cert_type = PRIVATE_CERTIFICATE_RT + + if cert_type == PRIVATE_CERTIFICATE_RT: + certs = list_certificates(cmd, name, resource_group_name, location, certificate, thumbprint) + if len(certs) == 0: + msg = "'{}'".format(cert_name) if cert_name else "with thumbprint '{}'".format(thumbprint) + raise ResourceNotFoundError(f"The certificate {msg} does not exist in Container app environment '{name}'.") + for cert in certs: + try: + ManagedEnvironmentClient.delete_certificate(cmd, resource_group_name, name, cert["name"]) + logger.warning('Successfully deleted certificate: %s', cert["name"]) + except Exception as e: + handle_raw_exception(e) + elif cert_type == MANAGED_CERTIFICATE_RT: + try: + ManagedEnvironmentClient.delete_managed_certificate(cmd, resource_group_name, name, cert_name) + logger.warning('Successfully deleted certificate: {}'.format(cert_name)) + except Exception as e: + handle_raw_exception(e) + else: + managed_certs = list(filter(lambda c: c["name"] == cert_name, get_managed_certificates(cmd, name, resource_group_name, None, location))) + private_certs = list(filter(lambda c: c["name"] == cert_name, get_private_certificates(cmd, name, resource_group_name, None, None, location))) + if len(managed_certs) == 0 and len(private_certs) == 0: + raise ResourceNotFoundError(f"The certificate '{cert_name}' does not exist in Container app environment '{name}'.") + if len(managed_certs) > 0 and len(private_certs) > 0: + raise RequiredArgumentMissingError(f"Found more than one certificates with name '{cert_name}':\n'{managed_certs[0]['id']}',\n'{private_certs[0]['id']}'.\nPlease specify the certificate id using --certificate.") try: - ManagedEnvironmentClient.delete_certificate(cmd, resource_group_name, name, cert["name"]) - logger.warning('Successfully deleted certificate: {}'.format(cert["name"])) + ManagedEnvironmentClient.delete_managed_certificate(cmd, resource_group_name, name, cert_name) + logger.warning('Successfully deleted certificate: %s', cert_name) except Exception as e: handle_raw_exception(e) @@ -2838,58 +2912,103 @@ def upload_ssl(cmd, resource_group_name, name, environment, certificate_file, ho custom_domains = get_custom_domains(cmd, resource_group_name, name, location, environment) new_custom_domains = list(filter(lambda c: c["name"] != hostname, custom_domains)) + env_name = _get_name(environment) + logger.warning('Uploading certificate to %s.', env_name) if is_valid_resource_id(environment): - cert = upload_certificate(cmd, _get_name(environment), parse_resource_id(environment)["resource_group"], certificate_file, certificate_name, certificate_password, location) + cert = upload_certificate(cmd, env_name, parse_resource_id(environment)["resource_group"], certificate_file, certificate_name, certificate_password, location) else: - cert = upload_certificate(cmd, _get_name(environment), resource_group_name, certificate_file, certificate_name, certificate_password, location) + cert = upload_certificate(cmd, env_name, resource_group_name, certificate_file, certificate_name, certificate_password, location) cert_id = cert["id"] new_domain = ContainerAppCustomDomainModel new_domain["name"] = hostname new_domain["certificateId"] = cert_id new_custom_domains.append(new_domain) - + logger.warning('Adding hostname %s and binding to %s.', hostname, name) return patch_new_custom_domain(cmd, resource_group_name, name, new_custom_domains) -def bind_hostname(cmd, resource_group_name, name, hostname, thumbprint=None, certificate=None, location=None, environment=None): +def bind_hostname(cmd, resource_group_name, name, hostname, thumbprint=None, certificate=None, location=None, environment=None, validation_method=None): _validate_subscription_registered(cmd, CONTAINER_APPS_RP) - if not thumbprint and not certificate: - raise RequiredArgumentMissingError('Please specify at least one of parameters: --certificate and --thumbprint') if not environment and not certificate: raise RequiredArgumentMissingError('Please specify at least one of parameters: --certificate and --environment') if certificate and not is_valid_resource_id(certificate) and not environment: raise RequiredArgumentMissingError('Please specify the parameter: --environment') - passed, message = validate_hostname(cmd, resource_group_name, name, hostname) + standardized_hostname = hostname.lower() + passed, message = validate_hostname(cmd, resource_group_name, name, standardized_hostname) if not passed: raise ValidationError(message or 'Please configure the DNS records before adding the hostname.') - env_name = None - cert_name = None - cert_id = None + env_name = _get_name(environment) if environment else None + if certificate: if is_valid_resource_id(certificate): cert_id = certificate else: - cert_name = certificate - if environment: - env_name = _get_name(environment) - if not cert_id: - certs = list_certificates(cmd, env_name, resource_group_name, location, cert_name, thumbprint) + certs = list_certificates(cmd, env_name, resource_group_name, location, certificate, thumbprint) + if len(certs) == 0: + msg = "'{}' with thumbprint '{}'".format(certificate, thumbprint) if thumbprint else "'{}'".format(certificate) + raise ResourceNotFoundError(f"The certificate {msg} does not exist in Container app environment '{env_name}'.") + cert_id = certs[0]["id"] + elif thumbprint: + certs = list_certificates(cmd, env_name, resource_group_name, location, certificate, thumbprint) + if len(certs) == 0: + raise ResourceNotFoundError(f"The certificate with thumbprint '{thumbprint}' does not exist in Container app environment '{env_name}'.") cert_id = certs[0]["id"] + else: # look for or create a managed certificate if no certificate info provided + managed_certs = get_managed_certificates(cmd, env_name, resource_group_name, None, None) + managed_cert = [cert for cert in managed_certs if cert["properties"]["subjectName"].lower() == standardized_hostname] + if len(managed_cert) > 0 and managed_cert[0]["properties"]["provisioningState"] in [SUCCEEDED_STATUS, PENDING_STATUS]: + cert_id = managed_cert[0]["id"] + cert_name = managed_cert[0]["name"] + else: + cert_name = None + while not cert_name: + random_name = generate_randomized_managed_cert_name(standardized_hostname, env_name) + available = check_managed_cert_name_availability(cmd, resource_group_name, env_name, cert_name) + if available: + cert_name = random_name + logger.warning("Creating managed certificate '%s' for %s.\nIt may take up to 20 minutes to create and issue a managed certificate.", cert_name, standardized_hostname) + + validation = validation_method + while validation not in ["TXT", "CNAME", "HTTP"]: + validation = prompt_str('\nPlease choose one of the following domain validation methods: TXT, CNAME, HTTP\nYour answer: ') + + certificate_envelop = prepare_managed_certificate_envelop(cmd, env_name, resource_group_name, standardized_hostname, validation_method, location) + try: + managed_cert = ManagedEnvironmentClient.create_or_update_managed_certificate(cmd, resource_group_name, env_name, cert_name, certificate_envelop, False, validation_method == 'TXT') + except Exception as e: + handle_raw_exception(e) + cert_id = managed_cert["id"] + + logger.warning("\nBinding managed certificate '%s' to %s\n", cert_name, standardized_hostname) custom_domains = get_custom_domains(cmd, resource_group_name, name, location, environment) - new_custom_domains = list(filter(lambda c: safe_get(c, "name", default=[]) != hostname, custom_domains)) + new_custom_domains = list(filter(lambda c: safe_get(c, "name", default=[]) != standardized_hostname, custom_domains)) new_domain = ContainerAppCustomDomainModel - new_domain["name"] = hostname + new_domain["name"] = standardized_hostname new_domain["certificateId"] = cert_id new_custom_domains.append(new_domain) return patch_new_custom_domain(cmd, resource_group_name, name, new_custom_domains) +def add_hostname(cmd, resource_group_name, name, hostname, location=None): + _validate_subscription_registered(cmd, CONTAINER_APPS_RP) + standardized_hostname = hostname.lower() + custom_domains = get_custom_domains(cmd, resource_group_name, name, location, None) + existing_hostname = list(filter(lambda c: safe_get(c, "name", default=[]) == standardized_hostname, custom_domains)) + if len(existing_hostname) > 0: + raise InvalidArgumentValueError("'{standardized_hostname}' already exists in container app '{name}'.") + new_domain = ContainerAppCustomDomainModel + new_domain["name"] = standardized_hostname + new_domain["bindingType"] = "Disabled" + custom_domains.append(new_domain) + return patch_new_custom_domain(cmd, resource_group_name, name, custom_domains) + + def list_hostname(cmd, resource_group_name, name, location=None): _validate_subscription_registered(cmd, CONTAINER_APPS_RP) diff --git a/src/containerapp/azext_containerapp/tests/latest/test_containerapp_env_commands.py b/src/containerapp/azext_containerapp/tests/latest/test_containerapp_env_commands.py index 27ae9a03e31..ff668cab909 100644 --- a/src/containerapp/azext_containerapp/tests/latest/test_containerapp_env_commands.py +++ b/src/containerapp/azext_containerapp/tests/latest/test_containerapp_env_commands.py @@ -195,7 +195,8 @@ def test_containerapp_env_dapr_components(self, resource_group): @live_only() # encounters 'CannotOverwriteExistingCassetteException' only when run from recording (passes when run live) @ResourceGroupPreparer(location="northeurope") def test_containerapp_env_certificate_e2e(self, resource_group): - self.cmd('configure --defaults location={}'.format(TEST_LOCATION)) + location = 'northcentralusstage' + self.cmd('configure --defaults location=northcentralusstage'.format(location)) env_name = self.create_random_name(prefix='containerapp-e2e-env', length=24) logs_workspace_name = self.create_random_name(prefix='containerapp-env', length=24) @@ -261,9 +262,62 @@ def test_containerapp_env_certificate_e2e(self, resource_group): JMESPathCheck('[0].id', cert_id), JMESPathCheck('[0].properties.thumbprint', cert_thumbprint), ]) + + # create a container app + ca_name = self.create_random_name(prefix='containerapp', length=24) + app = self.cmd('containerapp create -g {} -n {} --environment {} --ingress external --target-port 80'.format(resource_group, ca_name, env_name)).get_output_in_json() + + # create an App service domain and update its DNS records + contacts = os.path.join(TEST_DIR, 'domain-contact.json') + zone_name = "{}.com".format(ca_name) + subdomain_1 = "devtest" + txt_name_1 = "asuid.{}".format(subdomain_1) + hostname_1 = "{}.{}".format(subdomain_1, zone_name) + verification_id = app["properties"]["customDomainVerificationId"] + fqdn = app["properties"]["configuration"]["ingress"]["fqdn"] + self.cmd("appservice domain create -g {} --hostname {} --contact-info=@'{}' --accept-terms".format(resource_group, zone_name, contacts)).get_output_in_json() + self.cmd('network dns record-set txt add-record -g {} -z {} -n {} -v {}'.format(resource_group, zone_name, txt_name_1, verification_id)).get_output_in_json() + self.cmd('network dns record-set cname create -g {} -z {} -n {}'.format(resource_group, zone_name, subdomain_1)).get_output_in_json() + self.cmd('network dns record-set cname set-record -g {} -z {} -n {} -c {}'.format(resource_group, zone_name, subdomain_1, fqdn)).get_output_in_json() + + # add hostname without binding + self.cmd('containerapp hostname add -g {} -n {} --hostname {}'.format(resource_group, ca_name, hostname_1), checks={ + JMESPathCheck('length(@)', 1), + JMESPathCheck('[0].name', hostname_1), + JMESPathCheck('[0].bindingType', "Disabled"), + }) + self.cmd('containerapp hostname add -g {} -n {} --hostname {}'.format(resource_group, ca_name, hostname_1), expect_failure=True) + + # create a managed certificate + self.cmd('containerapp env certificate create -n {} -g {} --hostname {} -v CNAME -c {}'.format(env_name, resource_group, hostname_1, cert_name), checks=[ + JMESPathCheck('type', "Microsoft.App/managedEnvironments/managedCertificates"), + JMESPathCheck('name', cert_name), + JMESPathCheck('properties.subjectName', hostname_1), + ]).get_output_in_json() + + self.cmd('containerapp env certificate create -n {} -g {} --hostname {} -v CNAME'.format(env_name, resource_group, hostname_1), expect_failure=True) + self.cmd('containerapp env certificate list -g {} -n {} -m'.format(resource_group, env_name), checks=[ + JMESPathCheck('length(@)', 1), + ]) + self.cmd('containerapp env certificate list -g {} -n {} -c {}'.format(resource_group, env_name, cert_name), checks=[ + JMESPathCheck('length(@)', 2), + ]) + self.cmd('containerapp env certificate delete -n {} -g {} --certificate {} --yes'.format(env_name, resource_group, cert_name), expect_failure=True) self.cmd('containerapp env certificate delete -n {} -g {} --thumbprint {} --yes'.format(env_name, resource_group, cert_thumbprint)) + self.cmd('containerapp env certificate delete -n {} -g {} --certificate {} --yes'.format(env_name, resource_group, cert_name)) + self.cmd('containerapp env certificate list -g {} -n {}'.format(resource_group, env_name), checks=[ + JMESPathCheck('length(@)', 0), + ]) + + self.cmd('containerapp hostname bind -g {} -n {} --hostname {} --environment {} -v CNAME'.format(resource_group, ca_name, hostname_1, env_name)) + certs = self.cmd('containerapp env certificate list -g {} -n {}'.format(resource_group, env_name), checks=[ + JMESPathCheck('length(@)', 1), + ]).get_output_in_json() + self.cmd('containerapp env certificate delete -n {} -g {} --certificate {} --yes'.format(env_name, resource_group, certs[0]["name"]), expect_failure=True) + self.cmd('containerapp hostname delete -g {} -n {} --hostname {} --yes'.format(resource_group, ca_name, hostname_1)) + self.cmd('containerapp env certificate delete -n {} -g {} --certificate {} --yes'.format(env_name, resource_group, certs[0]["name"])) self.cmd('containerapp env certificate list -g {} -n {}'.format(resource_group, env_name), checks=[ JMESPathCheck('length(@)', 0), ]) diff --git a/src/containerapp/setup.py b/src/containerapp/setup.py index 16a134707b4..96a31426d53 100644 --- a/src/containerapp/setup.py +++ b/src/containerapp/setup.py @@ -17,7 +17,7 @@ # TODO: Confirm this is the right version number you want and it matches your # HISTORY.rst entry. -VERSION = '0.3.21' +VERSION = '0.3.22' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers