Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[containerapp] az containerapp create/update: Support --customized-keys and clientType in --bind for dev service #6939

Merged
merged 21 commits into from
Nov 10, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/containerapp/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ upcoming
* 'az container app create/update': support --logs-dynamic-json-columns/-j to configure whether to parse json string log into dynamic json columns
* 'az container app create/update/up': Remove the region check for the Cloud Build feature
* 'az container app create/update/up': Improve logs on the local buildpack source to cloud flow
* 'az containerapp create/update': Support --customized-keys and clientType in --bind for dev service

0.3.43
++++++
Expand Down
7 changes: 5 additions & 2 deletions src/containerapp/azext_containerapp/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
file_type,
get_three_state_flag, get_enum_type, tags_type)

from .action import AddCustomizedKeys
from ._validators import (validate_env_name_or_id,
validate_custom_location_name_or_id)
from ._constants import MAXIMUM_CONTAINER_APP_NAME_LENGTH
Expand All @@ -24,8 +25,9 @@ def load_arguments(self, _):
c.argument('artifact', help="Local path to the application artifact for building the container image. See the supported artifacts here: https://aka.ms/SourceToCloudSupportedArtifacts.", is_preview=True)

# Springboard
with self.argument_context('containerapp create', arg_group='Service Binding') as c:
with self.argument_context('containerapp create', arg_group='Service Binding', is_preview=True) as c:
Greedygre marked this conversation as resolved.
Show resolved Hide resolved
c.argument('service_bindings', nargs='*', options_list=['--bind'], help="Space separated list of services(bindings) to be connected to this app. e.g. SVC_NAME1[:BIND_NAME1] SVC_NAME2[:BIND_NAME2]...")
c.argument('customized_keys', options_list=['--customized-keys'], action=AddCustomizedKeys, nargs='*', help='The customized keys used to change default configuration names. Key is the original name, value is the customized name.')
Greedygre marked this conversation as resolved.
Show resolved Hide resolved
c.argument('service_type', help="The service information for dev services.")
c.ignore('service_type')

Expand All @@ -44,8 +46,9 @@ def load_arguments(self, _):
c.argument('artifact', help="Local path to the application artifact for building the container image. See the supported artifacts here: https://aka.ms/SourceToCloudSupportedArtifacts.", is_preview=True)

# Springboard
with self.argument_context('containerapp update', arg_group='Service Binding') as c:
with self.argument_context('containerapp update', arg_group='Service Binding', is_preview=True) as c:
c.argument('service_bindings', nargs='*', options_list=['--bind'], help="Space separated list of services(bindings) to be connected to this app. e.g. SVC_NAME1[:BIND_NAME1] SVC_NAME2[:BIND_NAME2]...")
c.argument('customized_keys', options_list=['--customized-keys'], action=AddCustomizedKeys, nargs='*', help='The customized keys used to change default configuration names. Key is the original name, value is the customized name.')
Greedygre marked this conversation as resolved.
Show resolved Hide resolved
c.argument('unbind_service_bindings', nargs='*', options_list=['--unbind'], help="Space separated list of services(bindings) to be removed from this app. e.g. BIND_NAME1...")

with self.argument_context('containerapp env', arg_group='Virtual Network') as c:
Expand Down
17 changes: 10 additions & 7 deletions src/containerapp/azext_containerapp/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@


def process_service(cmd, resource_list, service_name, arg_dict, subscription_id, resource_group_name, name,
binding_name, service_connector_def_list, service_bindings_def_list):
binding_name, service_connector_def_list, service_bindings_def_list, customized_keys=None):
# Check if the service exists in the list of dict
for service in resource_list:
if service["name"] == service_name:
Expand Down Expand Up @@ -74,11 +74,14 @@ def process_service(cmd, resource_list, service_name, arg_dict, subscription_id,

if service_type is None or service_type not in DEV_SERVICE_LIST:
raise ResourceNotFoundError(f"The service '{service_name}' does not exist")

service_bindings_def_list.append({
service_bind = {
"serviceId": containerapp_def["id"],
"name": binding_name
})
"name": binding_name,
"clientType": arg_dict.get("clientType")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will it is called by update command? If yes, when --client-type not provided, will it is cleared

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, for update command, we get the value from existing containerapp first, if the input clientType not exists, we will not set the value requestBody.
We have test for test update customizedKeys without clientType
image

if customized_keys:
service_bind["customizedKeys"] = customized_keys
service_bindings_def_list.append(service_bind)

else:
raise ValidationError("Service not supported")
Expand Down Expand Up @@ -140,7 +143,7 @@ def check_unique_bindings(cmd, service_connectors_def_list, service_bindings_def
return True


def parse_service_bindings(cmd, service_bindings_list, resource_group_name, name):
def parse_service_bindings(cmd, service_bindings_list, resource_group_name, name, customized_keys=None):
# Make it return both managed and dev bindings
service_bindings_def_list = []
service_connector_def_list = []
Expand Down Expand Up @@ -194,7 +197,7 @@ def parse_service_bindings(cmd, service_bindings_list, resource_group_name, name

# Will work for both create and update
process_service(cmd, resource_list, service_name, arg_dict, subscription_id, resource_group_name,
name, binding_name, service_connector_def_list, service_bindings_def_list)
name, binding_name, service_connector_def_list, service_bindings_def_list, customized_keys)

return service_connector_def_list, service_bindings_def_list

Expand Down
24 changes: 24 additions & 0 deletions src/containerapp/azext_containerapp/action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

import argparse
from collections import defaultdict
from azure.cli.core.azclierror import ValidationError


class AddCustomizedKeys(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
action = self.get_action(values, option_string)
namespace.customized_keys = action

def get_action(self, values, option_string): # pylint: disable=no-self-use
try:
properties = defaultdict(list)
for (k, v) in (x.split('=', 1) for x in values):
properties[k] = v
properties = dict(properties)
return properties
except ValueError:
raise ValidationError('Usage error: {} [DesiredKey=DefaultKey ...]'.format(option_string))
136 changes: 133 additions & 3 deletions src/containerapp/azext_containerapp/containerapp_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
ValidationError,
ArgumentUsageError,
ResourceNotFoundError,
MutuallyExclusiveArgumentError)
MutuallyExclusiveArgumentError,
InvalidArgumentValueError)
from azure.cli.command_modules.containerapp.containerapp_decorator import BaseContainerAppDecorator, ContainerAppCreateDecorator
from azure.cli.command_modules.containerapp._github_oauth import cache_github_token
from azure.cli.command_modules.containerapp._utils import (store_as_secret_and_return_secret_ref, parse_env_var_flags,
Expand Down Expand Up @@ -536,8 +537,20 @@ def set_up_update_containerapp_yaml(self, name, file_name):
tags = yaml_containerapp.get('tags')
del yaml_containerapp['tags']

# Save customizedKeys before converting from snake case to camel case, then re-add customizedKeys. We don't want to change the case of the customizedKeys.
service_binds = safe_get(yaml_containerapp, "properties", "template", "serviceBinds", default=[])
customized_keys_dict = {}
for bind in service_binds:
if bind.get("name") and bind.get("customizedKeys"):
customized_keys_dict[bind["name"]] = bind["customizedKeys"]

self.new_containerapp = _convert_object_from_snake_to_camel_case(_object_to_dict(self.new_containerapp))
self.new_containerapp['tags'] = tags
# Containerapp object to dictionary will lose "properties" level
service_binds = safe_get(self.new_containerapp, "properties", "template", "serviceBinds") or safe_get(self.new_containerapp, "template", "serviceBinds")
if service_binds:
for bind in service_binds:
bind["customizedKeys"] = customized_keys_dict.get(bind["name"])

# After deserializing, some properties may need to be moved under the "properties" attribute. Need this since we're not using SDK
self.new_containerapp = process_loaded_yaml(self.new_containerapp)
Expand Down Expand Up @@ -584,6 +597,9 @@ def get_argument_service_type(self):
def get_argument_service_bindings(self):
return self.get_param("service_bindings")

def get_argument_customized_keys(self):
return self.get_param("customized_keys")

def get_argument_service_connectors_def_list(self):
return self.get_param("service_connectors_def_list")

Expand All @@ -601,6 +617,8 @@ def construct_payload(self):
def validate_arguments(self):
super().validate_arguments()
validate_create(self.get_argument_registry_identity(), self.get_argument_registry_pass(), self.get_argument_registry_user(), self.get_argument_registry_server(), self.get_argument_no_wait(), self.get_argument_source(), self.get_argument_artifact(), self.get_argument_repo(), self.get_argument_yaml(), self.get_argument_environment_type())
if self.get_argument_service_bindings() and len(self.get_argument_service_bindings()) > 1 and self.get_argument_customized_keys():
raise InvalidArgumentValueError("--bind have multiple values, but --customized-keys only can be set when --bind has single value.")
Greedygre marked this conversation as resolved.
Show resolved Hide resolved

def set_up_source(self):
from ._up_utils import (_validate_source_artifact_args)
Expand Down Expand Up @@ -768,14 +786,113 @@ def set_up_service_binds(self):
service_connectors_def_list, service_bindings_def_list = parse_service_bindings(self.cmd,
self.get_argument_service_bindings(),
self.get_argument_resource_group_name(),
self.get_argument_name())
self.get_argument_name(),
self.get_argument_customized_keys())
self.set_argument_service_connectors_def_list(service_connectors_def_list)
unique_bindings = check_unique_bindings(self.cmd, service_connectors_def_list, service_bindings_def_list,
self.get_argument_resource_group_name(), self.get_argument_name())
if not unique_bindings:
raise ValidationError("Binding names across managed and dev services should be unique.")
safe_set(self.containerapp_def, "properties", "template", "serviceBinds", value=service_bindings_def_list)

def set_up_create_containerapp_yaml(self, name, file_name):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you support update with --yaml

Copy link
Contributor Author

@Greedygre Greedygre Nov 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we support.

if self.get_argument_image() or self.get_argument_min_replicas() or self.get_argument_max_replicas() or self.get_argument_target_port() or self.get_argument_ingress() or \
self.get_argument_revisions_mode() or self.get_argument_secrets() or self.get_argument_env_vars() or self.get_argument_cpu() or self.get_argument_memory() or self.get_argument_registry_server() or \
self.get_argument_registry_user() or self.get_argument_registry_pass() or self.get_argument_dapr_enabled() or self.get_argument_dapr_app_port() or self.get_argument_dapr_app_id() or \
self.get_argument_startup_command() or self.get_argument_args() or self.get_argument_tags():
not self.get_argument_disable_warnings() and logger.warning(
'Additional flags were passed along with --yaml. These flags will be ignored, and the configuration defined in the yaml will be used instead')

yaml_containerapp = process_loaded_yaml(load_yaml_file(file_name))

if not yaml_containerapp.get('name'):
yaml_containerapp['name'] = name
elif yaml_containerapp.get('name').lower() != name.lower():
logger.warning(
'The app name provided in the --yaml file "{}" does not match the one provided in the --name flag "{}". The one provided in the --yaml file will be used.'.format(
yaml_containerapp.get('name'), name))
name = yaml_containerapp.get('name')

if not yaml_containerapp.get('type'):
yaml_containerapp['type'] = 'Microsoft.App/containerApps'
elif yaml_containerapp.get('type').lower() != "microsoft.app/containerapps":
raise ValidationError('Containerapp type must be \"Microsoft.App/ContainerApps\"')

# Deserialize the yaml into a ContainerApp object. Need this since we're not using SDK
try:
deserializer = create_deserializer(self.models)

self.containerapp_def = deserializer('ContainerApp', yaml_containerapp)
except DeserializationError as ex:
raise ValidationError(
'Invalid YAML provided. Please see https://aka.ms/azure-container-apps-yaml for a valid containerapps YAML spec.') from ex

# Remove tags before converting from snake case to camel case, then re-add tags. We don't want to change the case of the tags. Need this since we're not using SDK
tags = None
if yaml_containerapp.get('tags'):
tags = yaml_containerapp.get('tags')
del yaml_containerapp['tags']

# Save customizedKeys before converting from snake case to camel case, then re-add customizedKeys. We don't want to change the case of the customizedKeys.
service_binds = safe_get(yaml_containerapp, "properties", "template", "serviceBinds", default=[])
customized_keys_dict = {}
for bind in service_binds:
if bind.get("name") and bind.get("customizedKeys"):
customized_keys_dict[bind["name"]] = bind["customizedKeys"]

self.containerapp_def = _convert_object_from_snake_to_camel_case(_object_to_dict(self.containerapp_def))
self.containerapp_def['tags'] = tags
# Containerapp object to dictionary will lose "properties" level
service_binds = safe_get(self.containerapp_def, "properties", "template", "serviceBinds") or safe_get(self.containerapp_def, "template", "serviceBinds")
if service_binds:
for bind in service_binds:
if bind.get("name") and bind.get("customizedKeys"):
bind["customizedKeys"] = customized_keys_dict.get(bind["name"])

# After deserializing, some properties may need to be moved under the "properties" attribute. Need this since we're not using SDK
self.containerapp_def = process_loaded_yaml(self.containerapp_def)

# Remove "additionalProperties" and read-only attributes that are introduced in the deserialization. Need this since we're not using SDK
_remove_additional_attributes(self.containerapp_def)
_remove_readonly_attributes(self.containerapp_def)

# Remove extra workloadProfileName introduced in deserialization
if "workloadProfileName" in self.containerapp_def:
del self.containerapp_def["workloadProfileName"]

# Validate managed environment
env_id = self.containerapp_def["properties"]['environmentId']
env_info = None
if self.get_argument_managed_env():
if not self.get_argument_disable_warnings() and env_id is not None and env_id != self.get_argument_managed_env():
logger.warning('The environmentId was passed along with --yaml. The value entered with --environment will be ignored, and the configuration defined in the yaml will be used instead')
if env_id is None:
env_id = self.get_argument_managed_env()
safe_set(self.containerapp_def, "properties", "environmentId", value=env_id)

if not self.containerapp_def["properties"].get('environmentId'):
raise RequiredArgumentMissingError(
'environmentId is required. This can be retrieved using the `az containerapp env show -g MyResourceGroup -n MyContainerappEnvironment --query id` command. Please see https://aka.ms/azure-container-apps-yaml for a valid containerapps YAML spec.')

if is_valid_resource_id(env_id):
parsed_managed_env = parse_resource_id(env_id)
env_name = parsed_managed_env['name']
env_rg = parsed_managed_env['resource_group']
else:
raise ValidationError('Invalid environmentId specified. Environment not found')

try:
env_info = self.get_environment_client().show(cmd=self.cmd, resource_group_name=env_rg, name=env_name)
except Exception as e:
handle_non_404_status_code_exception(e)

if not env_info:
raise ValidationError("The environment '{}' in resource group '{}' was not found".format(env_name, env_rg))

# Validate location
if not self.containerapp_def.get('location'):
self.containerapp_def['location'] = env_info['location']

def get_environment_client(self):
if self.get_argument_yaml():
env = safe_get(self.containerapp_def, "properties", "environmentId")
Expand Down Expand Up @@ -848,6 +965,9 @@ class ContainerAppPreviewUpdateDecorator(ContainerAppUpdateDecorator):
def get_argument_service_bindings(self):
return self.get_param("service_bindings")

def get_argument_customized_keys(self):
return self.get_param("customized_keys")

def get_argument_service_connectors_def_list(self):
return self.get_param("service_connectors_def_list")

Expand All @@ -866,6 +986,12 @@ def set_argument_source(self, source):
def get_argument_artifact(self):
return self.get_param("artifact")

def validate_arguments(self):
super().validate_arguments()
if self.get_argument_service_bindings() and len(self.get_argument_service_bindings()) > 1 and self.get_argument_customized_keys():
raise InvalidArgumentValueError(
"--bind have multiple values, but --customized-keys only can be set when --bind has single value.")

def construct_payload(self):
super().construct_payload()
self.set_up_service_bindings()
Expand Down Expand Up @@ -978,7 +1104,7 @@ def set_up_service_bindings(self):
if self.get_argument_service_bindings() is not None:
linker_client = get_linker_client(self.cmd)

service_connectors_def_list, service_bindings_def_list = parse_service_bindings(self.cmd, self.get_argument_service_bindings(), self.get_argument_resource_group_name(), self.get_argument_name())
service_connectors_def_list, service_bindings_def_list = parse_service_bindings(self.cmd, self.get_argument_service_bindings(), self.get_argument_resource_group_name(), self.get_argument_name(), self.get_argument_customized_keys())
self.set_argument_service_connectors_def_list(service_connectors_def_list)
service_bindings_used_map = {update_item["name"]: False for update_item in service_bindings_def_list}

Expand All @@ -991,6 +1117,10 @@ def set_up_service_bindings(self):
for update_item in service_bindings_def_list:
if update_item["name"] in item.values():
item["serviceId"] = update_item["serviceId"]
if update_item.get("clientType"):
item["clientType"] = update_item.get("clientType")
if update_item.get("customizedKeys"):
item["customizedKeys"] = update_item.get("customizedKeys")
service_bindings_used_map[update_item["name"]] = True

for update_item in service_bindings_def_list:
Expand Down
Loading