Skip to content
1 change: 1 addition & 0 deletions src/containerapp/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Release History
===============
upcoming
++++++
* Add command group 'az containerapp connected-env', support show/list/delete/create connected environment

0.3.38
++++++
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 azure.mgmt.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
46 changes: 46 additions & 0 deletions src/containerapp/azext_containerapp/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -1822,3 +1822,49 @@
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
"""
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
}
14 changes: 13 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,15 @@ 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)

with self.argument_context('containerapp connected-env', arg_group='Dapr') as c:
c.argument('dapr_ai_connection_string', options_list=['--dapr-ai-connection-string', '--dapr-connection'], help='Connection string used for Dapr application insights.')
Comment thread
Greedygre marked this conversation as resolved.
Outdated

with self.argument_context('containerapp connected-env', arg_group='Network') as c:
Comment thread
Greedygre marked this conversation as resolved.
Outdated
c.argument('static_ip', help='Static IP value.')
Comment thread
Greedygre marked this conversation as resolved.
Outdated
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["name"]
Comment thread
Greedygre marked this conversation as resolved.
Outdated
custom_loc_rg = parsed_custom_loc["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
check_extension_type = False
Comment thread
Greedygre marked this conversation as resolved.
Outdated
for extension_id in r.cluster_extension_ids:
extension = get_cluster_extension(cmd, extension_id)
if extension.extension_type.lower() == CONTAINER_APP_EXTENSION_TYPE:
check_extension_type = True
break
if not check_extension_type:
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())
Comment thread
Greedygre marked this conversation as resolved.
89 changes: 89 additions & 0 deletions src/containerapp/azext_containerapp/connected_env_decorator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------
from typing import Any, Dict

from azure.cli.core.commands import AzCliCommand
from msrestazure.tools import is_valid_resource_id

from ._client_factory import handle_raw_exception
from .base_resource import BaseResource
from ._constants import CONNECTED_ENVIRONMENT_RESOURCE_TYPE, CONTAINER_APP_EXTENSION_TYPE
from ._models import ConnectedEnvironment as ConnectedEnvironmentModel, ExtendedLocation as ExtendedLocationModel
from ._utils import safe_set, _ensure_location_allowed, list_environment_locations, validate_environment_location, \
get_custom_location, get_cluster_extension, validate_custom_location
from azure.cli.core.azclierror import ArgumentUsageError, ValidationError, ResourceNotFoundError


class ConnectedEnvironmentDecorator(BaseResource):
def __init__(
self, cmd: AzCliCommand, client: Any, raw_parameters: Dict, models: str
):
super().__init__(cmd, client, raw_parameters, models)

def get_argument_custom_location(self):
return self.get_param("custom_location")

def get_argument_location(self):
return self.get_param("location")

def get_argument_tags(self):
return self.get_param("tags")

def get_argument_static_ip(self):
return self.get_param("static_ip")

def get_argument_dapr_ai_connection_string(self):
return self.get_param("dapr_ai_connection_string")

def set_argument_location(self, location):
self.set_param("location", location)

def list(self):
connected_envs = super().list()
custom_location = self.get_argument_custom_location()
if custom_location:
connected_envs = [c for c in connected_envs if c["extendedLocation"]["name"].lower() == custom_location.lower()]

return connected_envs


class ConnectedEnvironmentCreateDecorator(ConnectedEnvironmentDecorator):
def __init__(
self, cmd: AzCliCommand, client: Any, raw_parameters: Dict, models: str
):
super().__init__(cmd, client, raw_parameters, models)
self.connected_env_def = ConnectedEnvironmentModel

def validate_arguments(self):
location = validate_environment_location(self.cmd, self.get_argument_location(), CONNECTED_ENVIRONMENT_RESOURCE_TYPE)
self.set_argument_location(location)
validate_custom_location(self.cmd, self.get_argument_custom_location())

def construct_payload(self):
self.connected_env_def["location"] = self.get_argument_location()
if self.get_argument_dapr_ai_connection_string():
self.connected_env_def["properties"]["daprAIConnectionString"] = self.get_argument_dapr_ai_connection_string()

if self.get_argument_static_ip():
self.connected_env_def["properties"]["staticIp"] = self.get_argument_static_ip()

if self.get_argument_tags():
self.connected_env_def["tags"] = self.get_argument_tags()

if self.get_argument_custom_location():
extended_location_def = ExtendedLocationModel
extended_location_def["name"] = self.get_argument_custom_location()
extended_location_def["type"] = "CustomLocation"
self.connected_env_def["extendedLocation"] = extended_location_def

def create(self):
try:
r = self.client.create(
cmd=self.cmd, resource_group_name=self.get_argument_resource_group_name(), name=self.get_argument_name(),
connected_environment_envelope=self.connected_env_def, no_wait=self.get_argument_no_wait())

return r
except Exception as e:
handle_raw_exception(e)
Loading