Skip to content
4 changes: 4 additions & 0 deletions src/containerapp/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Release History
===============
upcoming
++++++
* Add command group 'az containerapp connected-env', support show/list/delete/create connected environment

0.3.39
++++++
* 'az containerapp update': fix bug for populating secret value with --yaml
Expand Down
15 changes: 15 additions & 0 deletions src/containerapp/azext_containerapp/_client_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,18 @@ def log_analytics_shared_key_client_factory(cli_ctx):
from azure.mgmt.loganalytics import LogAnalyticsManagementClient

return get_mgmt_service_client(cli_ctx, LogAnalyticsManagementClient).shared_keys


def custom_location_client_factory(cli_ctx, api_version=None, subscription_id=None, **_):
from azure.cli.core.profiles import ResourceType
from azure.cli.core.commands.client_factory import get_mgmt_service_client

return get_mgmt_service_client(cli_ctx, ResourceType.MGMT_CUSTOMLOCATION, api_version=api_version,
subscription_id=subscription_id).custom_locations


def k8s_extension_client_factory(cli_ctx, subscription_id=None):
from azext_containerapp.vendored_sdks.kubernetesconfiguration import SourceControlConfigurationClient

r = get_mgmt_service_client(cli_ctx, SourceControlConfigurationClient, subscription_id=subscription_id)
return r.extensions
3 changes: 3 additions & 0 deletions src/containerapp/azext_containerapp/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
CONNECTED_ENVIRONMENT_TYPE = "connected"
MANAGED_ENVIRONMENT_RESOURCE_TYPE = "managedEnvironments"
CONNECTED_ENVIRONMENT_RESOURCE_TYPE = "connectedEnvironments"
CUSTOM_LOCATION_RESOURCE_TYPE = "customLocations"

MAXIMUM_SECRET_LENGTH = 20
MAXIMUM_CONTAINER_APP_NAME_LENGTH = 32
Expand All @@ -20,6 +21,8 @@
LOG_ANALYTICS_RP = "Microsoft.OperationalInsights"
CONTAINER_APPS_RP = "Microsoft.App"
SERVICE_LINKER_RP = "Microsoft.ServiceLinker"
EXTENDED_LOCATION_RP = "Microsoft.ExtendedLocation"
CONTAINER_APP_EXTENSION_TYPE = "microsoft.app.environment"

MANAGED_CERTIFICATE_RT = "managedCertificates"
PRIVATE_CERTIFICATE_RT = "certificates"
Expand Down
119 changes: 107 additions & 12 deletions src/containerapp/azext_containerapp/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,18 +727,6 @@
"queueLength": "5" "queueName": "foo" \\
--scale-rule-auth "connection=my-connection-string-secret-name" \\
--image imageName
- name: Create a container apps job hosted on a Connected Environment.
text: |
az containerapp job create -n MyContainerappsjob -g MyResourceGroup \\
--environment MyContainerappConnectedEnv
--environment-type connected
--trigger-type Manual \\
--replica-timeout 5 \\
--replica-retry-limit 2 \\
--replica-completion-count 1 \\
--parallelism 1 \\
--image imageName \\
--workload-profile-name my-wlp
"""

helps['containerapp job update'] = """
Expand Down Expand Up @@ -1822,3 +1810,110 @@
text: |
az containerapp list --environment-type connected
"""

# Connected Environment Commands
helps['containerapp connected-env'] = """
type: group
short-summary: Commands to manage Container Apps Connected environments for use with Arc enabled Container Apps.
"""

helps['containerapp connected-env create'] = """
type: command
short-summary: Create a Container Apps connected environment.
long-summary: Create a Container Apps Connected environment for use with Arc enabled Container Apps. Environments are an isolation boundary around a collection of container apps.
examples:
- name: Create a connected environment
text: |
az containerapp connected-env create -n MyContainerappConnectedEnv -g MyResourceGroup \\
--location eastus --custom-location MyCustomLocationResourceID
"""

helps['containerapp connected-env delete'] = """
type: command
short-summary: Delete a Container Apps connected environment.
examples:
- name: Delete a connected environment.
text: az containerapp connected-env delete -n MyContainerappConnectedEnv -g MyResourceGroup
"""

helps['containerapp connected-env show'] = """
type: command
short-summary: Show details of a Container Apps connected environment.
examples:
- name: Show the details of a connected environment.
text: |
az containerapp connected-env show -n MyContainerappConnectedEnv -g MyResourceGroup
"""

helps['containerapp connected-env list'] = """
type: command
short-summary: List Container Apps connected environments by subscription or resource group.
examples:
- name: List connected environments in the current subscription.
text: |
az containerapp connected-env list
- name: List connected environments by resource group.
text: |
az containerapp connected-env list -g MyResourceGroup
"""

# Container Apps Job Commands

helps['containerapp job create'] = """
type: command
short-summary: Create a container apps job.
examples:
- name: Create a container apps job with Trigger Type as Manual.
text: |
az containerapp job create -n MyContainerappsjob -g MyResourceGroup \\
--environment MyContainerappEnv
--trigger-type Manual \\
--replica-timeout 5 \\
--replica-retry-limit 2 \\
--replica-completion-count 1 \\
--parallelism 1 \\
--image imageName \\
--workload-profile-name my-wlp
- name: Create a container apps job with Trigger Type as Schedule.
text: |
az containerapp job create -n MyContainerappsjob -g MyResourceGroup \\
--environment MyContainerappEnv
--trigger-type Schedule \\
--replica-timeout 5 \\
--replica-retry-limit 2 \\
--replica-completion-count 1 \\
--parallelism 1 \\
--cron-expression \"*/1 * * * *\" \\
--image imageName
- name: Create a container apps job with Trigger Type as Event.
text: |
az containerapp job create -n MyContainerappsjob -g MyResourceGroup \\
--environment MyContainerappEnv
--trigger-type Event \\
--replica-timeout 5 \\
--replica-retry-limit 2 \\
--replica-completion-count 1 \\
--parallelism 1 \\
--polling-interval 30 \\
--min-executions 0 \\
--max-executions 1 \\
--scale-rule-name queueJob \\
--scale-rule-type azure-queue \\
--scale-rule-metadata "accountName=mystorageaccountname" \\
"cloud=AzurePublicCloud" \\
"queueLength": "5" "queueName": "foo" \\
--scale-rule-auth "connection=my-connection-string-secret-name" \\
--image imageName
- name: Create a container apps job hosted on a Connected Environment.
text: |
az containerapp job create -n MyContainerappsjob -g MyResourceGroup \\
--environment MyContainerappConnectedEnv
--environment-type connected
--trigger-type Manual \\
--replica-timeout 5 \\
--replica-retry-limit 2 \\
--replica-completion-count 1 \\
--parallelism 1 \\
--image imageName \\
--workload-profile-name my-wlp
"""
17 changes: 17 additions & 0 deletions src/containerapp/azext_containerapp/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,3 +407,20 @@
"architectures": None,
"support": None,
}


# model for preview extension
ConnectedEnvironment = {
"extendedLocation": None,
"tags": None,
"location": None,
"properties": {
"staticIp": None,
"daprAIConnectionString": None
}
}

ExtendedLocation = {
"name": None,
"type": None
}
10 changes: 9 additions & 1 deletion src/containerapp/azext_containerapp/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from ._validators import (validate_memory, validate_cpu, validate_managed_env_name_or_id, validate_registry_server,
validate_registry_user, validate_registry_pass, validate_target_port, validate_ingress,
validate_storage_name_or_id, validate_cors_max_age, validate_env_name_or_id,
validate_allow_insecure)
validate_allow_insecure, validate_custom_location_name_or_id)
from ._constants import UNAUTHENTICATED_CLIENT_ACTION, FORWARD_PROXY_CONVENTION, MAXIMUM_CONTAINER_APP_NAME_LENGTH, LOG_TYPE_CONSOLE, LOG_TYPE_SYSTEM


Expand Down Expand Up @@ -519,3 +519,11 @@ def load_arguments(self, _):
with self.argument_context('containerapp') as c:
c.argument('managed_env', validator=validate_env_name_or_id, options_list=['--environment'], help="Name or resource ID of the container app's environment.")
c.argument('environment_type', arg_type=get_enum_type(["managed", "connected"]), help="Type of environment.", is_preview=True)

with self.argument_context('containerapp connected-env') as c:
c.argument('name', name_type, help='Name of the Container Apps connected environment.')
c.argument('resource_group_name', arg_type=resource_group_name_type)
c.argument('tags', arg_type=tags_type)
c.argument('custom_location', help="Resource ID of custom location. List using 'az customlocation list'.", validator=validate_custom_location_name_or_id)
c.argument('dapr_ai_connection_string', options_list=['--dapr-ai-connection-string', '-d'], help='Application Insights connection string used by Dapr to export Service to Service communication telemetry.')
c.argument('static_ip', help='Static IP of the connectedEnvironment.')
70 changes: 62 additions & 8 deletions src/containerapp/azext_containerapp/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,14 @@
from msrestazure.tools import parse_resource_id, is_valid_resource_id, resource_id

from ._clients import ContainerAppClient, ManagedEnvironmentClient, WorkloadProfileClient, ContainerAppsJobClient
from ._client_factory import handle_raw_exception, providers_client_factory, cf_resource_groups, log_analytics_client_factory, log_analytics_shared_key_client_factory
from ._client_factory import handle_raw_exception, providers_client_factory, cf_resource_groups, \
log_analytics_client_factory, log_analytics_shared_key_client_factory, custom_location_client_factory, \
k8s_extension_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, PENDING_STATUS, SUCCEEDED_STATUS, UPDATING_STATUS, DEV_SERVICE_LIST)
LOG_ANALYTICS_RP, CONTAINER_APPS_RP, CHECK_CERTIFICATE_NAME_AVAILABILITY_TYPE,
ACR_IMAGE_SUFFIX,
LOGS_STRING, PENDING_STATUS, SUCCEEDED_STATUS, UPDATING_STATUS, DEV_SERVICE_LIST,
MANAGED_ENVIRONMENT_RESOURCE_TYPE, CONTAINER_APP_EXTENSION_TYPE)
from ._models import (ContainerAppCustomDomainEnvelope as ContainerAppCustomDomainEnvelopeModel,
ManagedCertificateEnvelop as ManagedCertificateEnvelopModel,
ServiceConnector as ServiceConnectorModel)
Expand Down Expand Up @@ -1780,14 +1784,14 @@ def is_registry_msi_system(identity):
return identity.lower() == "system"


def validate_environment_location(cmd, location):
res_locations = list_environment_locations(cmd)
def validate_environment_location(cmd, location, resource_type=MANAGED_ENVIRONMENT_RESOURCE_TYPE):
res_locations = list_environment_locations(cmd, resource_type=resource_type)

allowed_locs = ", ".join(res_locations)

if location:
try:
_ensure_location_allowed(cmd, location, CONTAINER_APPS_RP, "managedEnvironments")
_ensure_location_allowed(cmd, location, CONTAINER_APPS_RP, resource_type)

return location
except Exception as e: # pylint: disable=broad-except
Expand All @@ -1796,12 +1800,12 @@ def validate_environment_location(cmd, location):
return res_locations[0]


def list_environment_locations(cmd):
def list_environment_locations(cmd, resource_type=MANAGED_ENVIRONMENT_RESOURCE_TYPE):
providers_client = providers_client_factory(cmd.cli_ctx, get_subscription_id(cmd.cli_ctx))
resource_types = getattr(providers_client.get(CONTAINER_APPS_RP), 'resource_types', [])
res_locations = []
for res in resource_types:
if res and getattr(res, 'resource_type', "") == "managedEnvironments":
if res and getattr(res, 'resource_type', "") == resource_type:
res_locations = getattr(res, 'locations', [])

res_locations = [res_loc.lower().replace(" ", "").replace("(", "").replace(")", "") for res_loc in res_locations if res_loc.strip()]
Expand Down Expand Up @@ -2083,3 +2087,53 @@ def parse_oryx_mariner_tag(tag: str) -> OryxMarinerRunImgTagProperty:
else:
tag_obj = None
return tag_obj


def get_custom_location(cmd, custom_location_id):
parsed_custom_loc = parse_resource_id(custom_location_id)
subscription_id = parsed_custom_loc.get("subscription")
custom_loc_name = parsed_custom_loc.get("name")
custom_loc_rg = parsed_custom_loc.get("resource_group")
custom_location = None
try:
custom_location = custom_location_client_factory(cmd.cli_ctx, subscription_id=subscription_id).get(resource_group_name=custom_loc_rg, resource_name=custom_loc_name)
except ResourceNotFoundError:
pass
return custom_location


def get_cluster_extension(cmd, cluster_extension_id=None):
parsed_extension = parse_resource_id(cluster_extension_id)
subscription_id = parsed_extension.get("subscription")
cluster_rg = parsed_extension.get("resource_group")
cluster_rp = parsed_extension.get("namespace")
cluster_type = parsed_extension.get("type")
cluster_name = parsed_extension.get("name")
resource_name = parsed_extension.get("resource_name")

return k8s_extension_client_factory(cmd.cli_ctx, subscription_id=subscription_id).get(
resource_group_name=cluster_rg,
cluster_rp=cluster_rp,
cluster_resource_name=cluster_type,
cluster_name=cluster_name,
extension_name=resource_name)


def validate_custom_location(cmd, custom_location=None):
if not is_valid_resource_id(custom_location):
raise ValidationError('{} is not a valid Azure resource ID.'.format(custom_location))

r = get_custom_location(cmd=cmd, custom_location_id=custom_location)
if r is None:
raise ResourceNotFoundError("Cannot find custom location with custom location ID {}".format(custom_location))

# check extension type
extension_existing = False
for extension_id in r.cluster_extension_ids:
extension = get_cluster_extension(cmd, extension_id)
if extension.extension_type.lower() == CONTAINER_APP_EXTENSION_TYPE.lower():
extension_existing = True
break
if not extension_existing:
raise ValidationError('There is no Microsoft.App.Environment extension found associated with custom location {}'.format(custom_location))
return r.location
20 changes: 19 additions & 1 deletion src/containerapp/azext_containerapp/_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
from ._ssh_utils import ping_container_app
from ._utils import safe_get, is_registry_msi_system
from ._constants import ACR_IMAGE_SUFFIX, LOG_TYPE_SYSTEM, CONNECTED_ENVIRONMENT_RESOURCE_TYPE, \
CONNECTED_ENVIRONMENT_TYPE, MANAGED_ENVIRONMENT_RESOURCE_TYPE, MANAGED_ENVIRONMENT_TYPE, CONTAINER_APPS_RP
CONNECTED_ENVIRONMENT_TYPE, MANAGED_ENVIRONMENT_RESOURCE_TYPE, MANAGED_ENVIRONMENT_TYPE, CONTAINER_APPS_RP, \
EXTENDED_LOCATION_RP, CUSTOM_LOCATION_RESOURCE_TYPE

logger = get_logger(__name__)

Expand Down Expand Up @@ -271,3 +272,20 @@ def validate_env_name_or_id(cmd, namespace):
type=MANAGED_ENVIRONMENT_RESOURCE_TYPE,
name=namespace.managed_env
)


def validate_custom_location_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

if not namespace.custom_location or not namespace.resource_group_name:
return

if not is_valid_resource_id(namespace.custom_location):
namespace.custom_location = resource_id(
subscription=get_subscription_id(cmd.cli_ctx),
resource_group=namespace.resource_group_name,
namespace=EXTENDED_LOCATION_RP,
type=CUSTOM_LOCATION_RESOURCE_TYPE,
name=namespace.custom_location
)
6 changes: 6 additions & 0 deletions src/containerapp/azext_containerapp/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,9 @@ def load_command_table(self, _):
g.custom_command('list', 'patch_list')
g.custom_command('apply', 'patch_apply')
g.custom_command('interactive', 'patch_interactive')

with self.command_group('containerapp connected-env', is_preview=True) as g:
g.custom_show_command('show', 'show_connected_environment')
g.custom_command('list', 'list_connected_environments')
g.custom_command('create', 'create_connected_environment', supports_no_wait=True, exception_handler=ex_handler_factory())
g.custom_command('delete', 'delete_connected_environment', supports_no_wait=True, confirmation=True, exception_handler=ex_handler_factory())
Loading