diff --git a/src/containerapp/azext_containerapp/_clients.py b/src/containerapp/azext_containerapp/_clients.py index d217e12be5e..e60ca71dbd8 100644 --- a/src/containerapp/azext_containerapp/_clients.py +++ b/src/containerapp/azext_containerapp/_clients.py @@ -167,7 +167,7 @@ def delete(cls, cmd, resource_group_name, name, no_wait=False): @classmethod def show(cls, cmd, resource_group_name, name): management_hostname = cmd.cli_ctx.cloud.endpoints.resource_manager - api_version = PREVIEW_API_VERSION + api_version = STABLE_API_VERSION sub_id = get_subscription_id(cmd.cli_ctx) url_fmt = "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.App/containerApps/{}?api-version={}" request_url = url_fmt.format( diff --git a/src/containerapp/azext_containerapp/_help.py b/src/containerapp/azext_containerapp/_help.py index ab4cde705eb..573610efecf 100644 --- a/src/containerapp/azext_containerapp/_help.py +++ b/src/containerapp/azext_containerapp/_help.py @@ -263,6 +263,32 @@ az containerapp revision copy -n MyContainerapp -g MyResourceGroup --cpu 0.75 --memory 1.5Gi """ +helps['containerapp revision label'] = """ + type: group + short-summary: Manage revision labels assigned to traffic weights. +""" + +helps['containerapp revision label add'] = """ + type: command + short-summary: Set a revision label to a revision with an associated traffic weight. + examples: + - name: Add a label to the latest revision. + text: | + az containerapp revision label add -n MyContainerapp -g MyResourceGroup --label myLabel --revision latest + - name: Add a label to a previous revision. + text: | + az containerapp revision label add -g MyResourceGroup --label myLabel --revision revisionName +""" + +helps['containerapp revision label remove'] = """ + type: command + short-summary: Remove a revision label from a revision with an associated traffic weight. + examples: + - name: Remove a label. + text: | + az containerapp revision label remove -n MyContainerapp -g MyResourceGroup --label myLabel +""" + # Environment Commands helps['containerapp env'] = """ type: group @@ -459,12 +485,18 @@ type: command short-summary: Configure traffic-splitting for a container app. examples: - - name: Route 100%% of a container app's traffic to its latest revision. + - name: Route 100% of a container app's traffic to its latest revision. text: | - az containerapp ingress traffic set -n MyContainerapp -g MyResourceGroup --traffic-weight latest=100 + az containerapp ingress traffic set -n MyContainerapp -g MyResourceGroup --revision-weight latest=100 - name: Split a container app's traffic between two revisions. text: | - az containerapp ingress traffic set -n MyContainerapp -g MyResourceGroup --traffic-weight latest=80 MyRevisionName=20 + az containerapp ingress traffic set -n MyContainerapp -g MyResourceGroup --revision-weight latest=80 MyRevisionName=20 + - name: Split a container app's traffic between two labels. + text: | + az containerapp ingress traffic set -n MyContainerapp -g MyResourceGroup --label-weight myLabel=80 myLabel2=20 + - name: Split a container app's traffic between a label and a revision. + text: | + az containerapp ingress traffic set -n MyContainerapp -g MyResourceGroup --revision-weight latest=80 --label-weight myLabel=20 """ helps['containerapp ingress traffic show'] = """ diff --git a/src/containerapp/azext_containerapp/_params.py b/src/containerapp/azext_containerapp/_params.py index e8c217df549..8dafd6da9c0 100644 --- a/src/containerapp/azext_containerapp/_params.py +++ b/src/containerapp/azext_containerapp/_params.py @@ -179,6 +179,11 @@ def load_arguments(self, _): c.argument('from_revision', help='Revision to copy from. Default: latest revision.') c.argument('image', options_list=['--image', '-i'], help="Container image, e.g. publisher/image-name:tag.") + with self.argument_context('containerapp revision label') as c: + c.argument('name', id_part=None) + c.argument('revision', help='Name of the revision.') + c.argument('label', help='Name of the label.') + with self.argument_context('containerapp ingress') as c: c.argument('allow_insecure', help='Allow insecure connections for ingress traffic.') c.argument('type', validator=validate_ingress, arg_type=get_enum_type(['internal', 'external']), help="The ingress type.") @@ -186,7 +191,8 @@ def load_arguments(self, _): c.argument('target_port', type=int, validator=validate_target_port, help="The application port used for ingress traffic.") with self.argument_context('containerapp ingress traffic') as c: - c.argument('traffic_weights', nargs='*', options_list=['--traffic-weight'], help="A list of revision weight(s) for the container app. Space-separated values in 'revision_name=weight' format. For latest revision, use 'latest=weight'") + c.argument('revision_weights', nargs='+', options_list=['--revision-weight'], help="A list of revision weight(s) for the container app. Space-separated values in 'revision_name=weight' format. For latest revision, use 'latest=weight'") + c.argument('label_weights', nargs='+', options_list=['--label-weight'], help="A list of label weight(s) for the container app. Space-separated values in 'label_name=weight' format.") with self.argument_context('containerapp secret') as c: c.argument('secrets', nargs='+', options_list=['--secrets', '-s'], help="A list of secret(s) for the container app. Space-separated values in 'key=value' format (where 'key' cannot be longer than 20 characters).") diff --git a/src/containerapp/azext_containerapp/_utils.py b/src/containerapp/azext_containerapp/_utils.py index ce2c89dbf95..93c6c79328d 100644 --- a/src/containerapp/azext_containerapp/_utils.py +++ b/src/containerapp/azext_containerapp/_utils.py @@ -830,42 +830,125 @@ def update_nested_dictionary(orig_dict, new_dict): return orig_dict -def _is_valid_weight(weight): +def _validate_weight(weight): try: n = int(weight) if 0 <= n <= 100: return True - return False - except ValueError: - return False + raise ValidationError('Traffic weights must be integers between 0 and 100') + except ValueError as ex: + raise ValidationError('Traffic weights must be integers between 0 and 100') from ex -def _update_traffic_weights(containerapp_def, list_weights): - if "traffic" not in containerapp_def["properties"]["configuration"]["ingress"] or list_weights and len(list_weights): +def _update_revision_weights(containerapp_def, list_weights): + old_weight_sum = 0 + if "traffic" not in containerapp_def["properties"]["configuration"]["ingress"]: containerapp_def["properties"]["configuration"]["ingress"]["traffic"] = [] + if not list_weights: + return 0 + for new_weight in list_weights: key_val = new_weight.split('=', 1) + if len(key_val) != 2: + raise ValidationError('Traffic weights must be in format \"= = ...\"') + revision = key_val[0] + weight = key_val[1] + _validate_weight(weight) is_existing = False + for existing_weight in containerapp_def["properties"]["configuration"]["ingress"]["traffic"]: + if "latestRevision" in existing_weight and existing_weight["latestRevision"]: + if revision.lower() == "latest": + old_weight_sum += existing_weight["weight"] + existing_weight["weight"] = weight + is_existing = True + break + elif "revisionName" in existing_weight and existing_weight["revisionName"].lower() == revision.lower(): + old_weight_sum += existing_weight["weight"] + existing_weight["weight"] = weight + is_existing = True + break + if not is_existing: + containerapp_def["properties"]["configuration"]["ingress"]["traffic"].append({ + "revisionName": revision if revision.lower() != "latest" else None, + "weight": int(weight), + "latestRevision": revision.lower() == "latest" + }) + return old_weight_sum + + +def _append_label_weights(containerapp_def, label_weights, revision_weights): + if "traffic" not in containerapp_def["properties"]["configuration"]["ingress"]: + containerapp_def["properties"]["configuration"]["ingress"]["traffic"] = [] + + if not label_weights: + return + + revision_weight_names = [w.split('=', 1)[0].lower() for w in revision_weights] # this is to check if we already have that revision weight passed + for new_weight in label_weights: + key_val = new_weight.split('=', 1) if len(key_val) != 2: - raise ValidationError('Traffic weights must be in format \"=weight = ...\"') + raise ValidationError('Traffic weights must be in format \"= = ...\"') + label = key_val[0] + weight = key_val[1] + _validate_weight(weight) + is_existing = False - if not _is_valid_weight(key_val[1]): - raise ValidationError('Traffic weights must be integers between 0 and 100') + for existing_weight in containerapp_def["properties"]["configuration"]["ingress"]["traffic"]: + if "label" in existing_weight and existing_weight["label"].lower() == label.lower(): + if "revisionName" in existing_weight and existing_weight["revisionName"] and existing_weight["revisionName"].lower() in revision_weight_names: + logger.warning("Already passed value for revision {}, will not overwrite with {}.".format(existing_weight["revisionName"], new_weight)) # pylint: disable=logging-format-interpolation + is_existing = True + break + revision_weights.append("{}={}".format(existing_weight["revisionName"] if "revisionName" in existing_weight and existing_weight["revisionName"] else "latest", weight)) + is_existing = True + break if not is_existing: - containerapp_def["properties"]["configuration"]["ingress"]["traffic"].append({ - "revisionName": key_val[0] if key_val[0].lower() != "latest" else None, - "weight": int(key_val[1]), - "latestRevision": key_val[0].lower() == "latest" - }) + raise ValidationError(f"No label {label} assigned to any traffic weight.") + + +def _update_weights(containerapp_def, revision_weights, old_weight_sum): + + new_weight_sum = sum([int(w.split('=', 1)[1]) for w in revision_weights]) + revision_weight_names = [w.split('=', 1)[0].lower() for w in revision_weights] + divisor = sum([int(w["weight"]) for w in containerapp_def["properties"]["configuration"]["ingress"]["traffic"]]) - new_weight_sum + round_up = True + # if there is no change to be made, don't even try (also can't divide by zero) + if divisor == 0: + return + + scale_factor = (old_weight_sum - new_weight_sum) / divisor + 1 + + for existing_weight in containerapp_def["properties"]["configuration"]["ingress"]["traffic"]: + if "latestRevision" in existing_weight and existing_weight["latestRevision"]: + if "latest" not in revision_weight_names: + existing_weight["weight"], round_up = _round(scale_factor * existing_weight["weight"], round_up) + elif "revisionName" in existing_weight and existing_weight["revisionName"].lower() not in revision_weight_names: + existing_weight["weight"], round_up = _round(scale_factor * existing_weight["weight"], round_up) + + +# required because what if .5, .5? We need sum to be 100, so can't round up or down both times +def _round(number, round_up): + import math + number = round(number, 2) # required because we are dealing with floats + if round_up: + return math.ceil(number), not round_up + return math.floor(number), not round_up + + +def _validate_traffic_sum(revision_weights): + weight_sum = sum([int(w.split('=', 1)[1]) for w in revision_weights if len(w.split('=', 1)) == 2 and _validate_weight(w.split('=', 1)[1])]) + if weight_sum > 100: + raise ValidationError("Traffic sums may not exceed 100.") def _get_app_from_revision(revision): if not revision: raise ValidationError('Invalid revision. Revision must not be empty') - + if revision.lower() == "latest": + raise ValidationError('Please provide a name for your containerapp. Cannot lookup name of containerapp without a full revision name.') revision = revision.split('--') revision.pop() revision = "--".join(revision) diff --git a/src/containerapp/azext_containerapp/commands.py b/src/containerapp/azext_containerapp/commands.py index 158616ca0b2..d6c60a9c063 100644 --- a/src/containerapp/azext_containerapp/commands.py +++ b/src/containerapp/azext_containerapp/commands.py @@ -27,7 +27,7 @@ def transform_containerapp_list_output(apps): def transform_revision_output(rev): - props = ['name', 'active', 'createdTime', 'trafficWeight'] + props = ['name', 'active', 'createdTime', 'trafficWeight', 'healthState', 'provisioningState', 'replicas'] result = {k: rev['properties'][k] for k in rev['properties'] if k in props} if 'name' in rev: @@ -92,6 +92,10 @@ def load_command_table(self, _): g.custom_command('copy', 'copy_revision', exception_handler=ex_handler_factory()) g.custom_command('set-mode', 'set_revision_mode', exception_handler=ex_handler_factory()) + with self.command_group('containerapp revision label') as g: + g.custom_command('add', 'add_revision_label') + g.custom_command('remove', 'remove_revision_label') + with self.command_group('containerapp ingress') as g: g.custom_command('enable', 'enable_ingress', exception_handler=ex_handler_factory()) g.custom_command('disable', 'disable_ingress', exception_handler=ex_handler_factory()) diff --git a/src/containerapp/azext_containerapp/custom.py b/src/containerapp/azext_containerapp/custom.py index 80c5bccf546..acb34e70008 100644 --- a/src/containerapp/azext_containerapp/custom.py +++ b/src/containerapp/azext_containerapp/custom.py @@ -50,11 +50,11 @@ parse_secret_flags, store_as_secret_and_return_secret_ref, parse_env_var_flags, _generate_log_analytics_if_not_provided, _get_existing_secrets, _convert_object_from_snake_to_camel_case, _object_to_dict, _add_or_update_secrets, _remove_additional_attributes, _remove_readonly_attributes, - _add_or_update_env_vars, _add_or_update_tags, update_nested_dictionary, _update_traffic_weights, + _add_or_update_env_vars, _add_or_update_tags, update_nested_dictionary, _update_revision_weights, _append_label_weights, _get_app_from_revision, raise_missing_token_suggestion, _infer_acr_credentials, _remove_registry_secret, _remove_secret, - _ensure_identity_resource_id, _remove_dapr_readonly_attributes, _remove_env_vars, + _ensure_identity_resource_id, _remove_dapr_readonly_attributes, _remove_env_vars, _validate_traffic_sum, _update_revision_env_secretrefs, _get_acr_cred, safe_get, await_github_action, repo_url_to_name, - validate_container_app_name) + validate_container_app_name, _update_weights) from ._ssh_utils import (SSH_DEFAULT_ENCODING, WebSocketConnection, read_ssh, get_stdin_writer, SSH_CTRL_C_MSG, SSH_BACKUP_ENCODING) @@ -1344,6 +1344,98 @@ def set_revision_mode(cmd, resource_group_name, name, mode, no_wait=False): handle_raw_exception(e) +def add_revision_label(cmd, resource_group_name, revision, label, name=None, no_wait=False): + _validate_subscription_registered(cmd, "Microsoft.App") + + if not name: + name = _get_app_from_revision(revision) + + containerapp_def = None + try: + containerapp_def = ContainerAppClient.show(cmd=cmd, resource_group_name=resource_group_name, name=name) + except: + pass + + if not containerapp_def: + raise ResourceNotFoundError(f"The containerapp '{name}' does not exist in group '{resource_group_name}'") + + if "ingress" not in containerapp_def['properties']['configuration'] and "traffic" not in containerapp_def['properties']['configuration']['ingress']: + raise ValidationError("Ingress and traffic weights are required to set labels.") + + traffic_weight = containerapp_def['properties']['configuration']['ingress']['traffic'] + + label_added = False + for weight in traffic_weight: + if "latestRevision" in weight: + if revision.lower() == "latest" and weight["latestRevision"]: + label_added = True + weight["label"] = label + break + else: + if revision.lower() == weight["revisionName"].lower(): + label_added = True + weight["label"] = label + break + + if not label_added: + raise ValidationError("Please specify a revision name with an associated traffic weight.") + + containerapp_patch_def = {} + containerapp_patch_def['properties'] = {} + containerapp_patch_def['properties']['configuration'] = {} + containerapp_patch_def['properties']['configuration']['ingress'] = {} + + containerapp_patch_def['properties']['configuration']['ingress']['traffic'] = traffic_weight + + try: + r = ContainerAppClient.update( + cmd=cmd, resource_group_name=resource_group_name, name=name, container_app_envelope=containerapp_patch_def, no_wait=no_wait) + return r['properties']['configuration']['ingress']['traffic'] + except Exception as e: + handle_raw_exception(e) + + +def remove_revision_label(cmd, resource_group_name, name, label, no_wait=False): + _validate_subscription_registered(cmd, "Microsoft.App") + + containerapp_def = None + try: + containerapp_def = ContainerAppClient.show(cmd=cmd, resource_group_name=resource_group_name, name=name) + except: + pass + + if not containerapp_def: + raise ResourceNotFoundError(f"The containerapp '{name}' does not exist in group '{resource_group_name}'") + + if "ingress" not in containerapp_def['properties']['configuration'] and "traffic" not in containerapp_def['properties']['configuration']['ingress']: + raise ValidationError("Ingress and traffic weights are required to set labels.") + + traffic_weight = containerapp_def['properties']['configuration']['ingress']['traffic'] + + label_removed = False + for weight in traffic_weight: + if "label" in weight and weight["label"].lower() == label.lower(): + label_removed = True + weight["label"] = None + break + if not label_removed: + raise ValidationError("Please specify a label name with an associated traffic weight.") + + containerapp_patch_def = {} + containerapp_patch_def['properties'] = {} + containerapp_patch_def['properties']['configuration'] = {} + containerapp_patch_def['properties']['configuration']['ingress'] = {} + + containerapp_patch_def['properties']['configuration']['ingress']['traffic'] = traffic_weight + + try: + r = ContainerAppClient.update( + cmd=cmd, resource_group_name=resource_group_name, name=name, container_app_envelope=containerapp_patch_def, no_wait=no_wait) + return r['properties']['configuration']['ingress']['traffic'] + except Exception as e: + handle_raw_exception(e) + + def show_ingress(cmd, name, resource_group_name): _validate_subscription_registered(cmd, "Microsoft.App") @@ -1427,8 +1519,10 @@ def disable_ingress(cmd, name, resource_group_name, no_wait=False): handle_raw_exception(e) -def set_ingress_traffic(cmd, name, resource_group_name, traffic_weights, no_wait=False): +def set_ingress_traffic(cmd, name, resource_group_name, label_weights=None, revision_weights=None, no_wait=False): _validate_subscription_registered(cmd, "Microsoft.App") + if not label_weights and not revision_weights: + raise ValidationError("Must specify either --label-weight or --revision-weight.") containerapp_def = None try: @@ -1437,22 +1531,38 @@ def set_ingress_traffic(cmd, name, resource_group_name, traffic_weights, no_wait pass if not containerapp_def: - raise ResourceNotFoundError("The containerapp '{}' does not exist".format(name)) + raise ResourceNotFoundError(f"The containerapp '{name}' does not exist in group '{resource_group_name}'") try: containerapp_def["properties"]["configuration"]["ingress"] + containerapp_def["properties"]["configuration"]["ingress"]["traffic"] except Exception as e: raise ValidationError("Ingress must be enabled to set ingress traffic. Try running `az containerapp ingress -h` for more info.") from e - if traffic_weights is not None: - _update_traffic_weights(containerapp_def, traffic_weights) + if not revision_weights: + revision_weights = [] - _get_existing_secrets(cmd, resource_group_name, name, containerapp_def) + # convert label weights to appropriate revision name + _append_label_weights(containerapp_def, label_weights, revision_weights) + + # validate sum is less than 100 + _validate_traffic_sum(revision_weights) + + # update revision weights to containerapp, get the old weight sum + old_weight_sum = _update_revision_weights(containerapp_def, revision_weights) + + _update_weights(containerapp_def, revision_weights, old_weight_sum) + + containerapp_patch_def = {} + containerapp_patch_def['properties'] = {} + containerapp_patch_def['properties']['configuration'] = {} + containerapp_patch_def['properties']['configuration']['ingress'] = {} + containerapp_patch_def['properties']['configuration']['ingress']['traffic'] = containerapp_def["properties"]["configuration"]["ingress"]["traffic"] try: - r = ContainerAppClient.create_or_update( - cmd=cmd, resource_group_name=resource_group_name, name=name, container_app_envelope=containerapp_def, no_wait=no_wait) - return r["properties"]["configuration"]["ingress"]["traffic"] + r = ContainerAppClient.update( + cmd=cmd, resource_group_name=resource_group_name, name=name, container_app_envelope=containerapp_patch_def, no_wait=no_wait) + return r['properties']['configuration']['ingress']['traffic'] except Exception as e: handle_raw_exception(e)