diff --git a/src/aks-preview/HISTORY.rst b/src/aks-preview/HISTORY.rst index c3ba5da027f..76ad5a46ed0 100644 --- a/src/aks-preview/HISTORY.rst +++ b/src/aks-preview/HISTORY.rst @@ -12,6 +12,7 @@ To release a new version, please select a new version number (usually plus 1 to Pending +++++++ * Add `--allowed-subjects-from-file` to `az aks identity-binding create` and add a new `az aks identity-binding update` command to manage the `allowedSubjects` list on identity bindings. +* Add `az aks alert-config` commands to manage AKS-managed alert configurations. 21.0.0b11 ++++++++ diff --git a/src/aks-preview/azext_aks_preview/_client_factory.py b/src/aks-preview/azext_aks_preview/_client_factory.py index 29c870828dd..aadcdcf4308 100644 --- a/src/aks-preview/azext_aks_preview/_client_factory.py +++ b/src/aks-preview/azext_aks_preview/_client_factory.py @@ -174,3 +174,7 @@ def cf_vm_skus(cli_ctx, *_): def cf_prepared_image_specifications(cli_ctx, *_, subscription_id=None): return get_container_service_pis_client(cli_ctx, subscription_id=subscription_id).prepared_image_specifications + + +def cf_alert_configurations(cli_ctx, *_): + return get_container_service_client(cli_ctx).alert_configurations diff --git a/src/aks-preview/azext_aks_preview/_format.py b/src/aks-preview/azext_aks_preview/_format.py index bc792055a86..610a3d4c118 100644 --- a/src/aks-preview/azext_aks_preview/_format.py +++ b/src/aks-preview/azext_aks_preview/_format.py @@ -612,3 +612,25 @@ def _get(obj, attr): ('restrictions', restrictions_summary), ('capabilities', capabilities_summary), ]) + + +def aks_alert_config_list_table_format(results): + """Format a list of alert configurations as summary results for display with "-o table". """ + return [_get_alert_config_table_row(result) for result in results] + + +def aks_alert_config_show_table_format(result): + """Format an alert configuration as summary results for display with "-o table". """ + return _get_alert_config_table_row(result) + + +def _get_alert_config_table_row(result): + """Extract information from an alert configuration for table display.""" + properties = result.get('properties') or {} + notification = properties.get('notification') or {} + return OrderedDict([ + ('name', result.get('name', '')), + ('mode', properties.get('mode', '')), + ('actionGroupId', notification.get('actionGroupId', '')), + ('provisioningState', properties.get('provisioningState', '')), + ]) diff --git a/src/aks-preview/azext_aks_preview/_help.py b/src/aks-preview/azext_aks_preview/_help.py index cf443bf17b1..4228a7a8b3f 100644 --- a/src/aks-preview/azext_aks_preview/_help.py +++ b/src/aks-preview/azext_aks_preview/_help.py @@ -4973,6 +4973,126 @@ text: az aks jwtauthenticator show -g MyResourceGroup --cluster-name MyCluster --name myjwt """ +helps['aks alert-config'] = """ + type: group + short-summary: Commands to manage AKS-managed alert configurations for a cluster. + long-summary: Alert configurations let AKS create and manage alerts for important cluster + events and conditions, delivering notifications through an Azure Monitor + action group. +""" + +helps['aks alert-config add'] = """ + type: command + short-summary: Add an alert configuration to a managed cluster. + parameters: + - name: --cluster-name + type: string + short-summary: Name of the managed cluster. + - name: --name -n + type: string + short-summary: Name of the alert configuration (must be unique within the cluster). + - name: --mode + type: string + short-summary: How AKS manages alerts for the cluster. + long-summary: Use Managed to have AKS create, update and delete the alerts and send + notifications to the configured action group. Use Disabled to turn + alerting off. + - name: --action-group-id + type: string + short-summary: Resource ID of the Azure Monitor action group to send notifications to. + long-summary: Optional. When omitted, the alert configuration is created without a + notification target and no notifications are delivered. + - name: --aks-custom-headers + type: string + short-summary: Send custom headers. When specified, format should be Key1=Value1,Key2=Value2 + examples: + - name: Add a managed alert configuration that notifies an action group + text: az aks alert-config add -g MyResourceGroup --cluster-name MyCluster -n myalerts --mode Managed --action-group-id /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyResourceGroup/providers/Microsoft.Insights/actionGroups/myag + - name: Add an alert configuration with alerting disabled + text: az aks alert-config add -g MyResourceGroup --cluster-name MyCluster -n myalerts --mode Disabled +""" + +helps['aks alert-config update'] = """ + type: command + short-summary: Update an alert configuration in a managed cluster. + long-summary: Only the properties you specify are changed; the rest are preserved. + parameters: + - name: --cluster-name + type: string + short-summary: Name of the managed cluster. + - name: --name -n + type: string + short-summary: Name of the alert configuration to update. + - name: --mode + type: string + short-summary: How AKS manages alerts for the cluster. + - name: --action-group-id + type: string + short-summary: Resource ID of the Azure Monitor action group to send notifications to. + long-summary: Pass an empty string to remove the current notification target. + - name: --aks-custom-headers + type: string + short-summary: Send custom headers. When specified, format should be Key1=Value1,Key2=Value2 + examples: + - name: Turn off alerting without changing the notification target + text: az aks alert-config update -g MyResourceGroup --cluster-name MyCluster -n myalerts --mode Disabled + - name: Point an existing alert configuration at a different action group + text: az aks alert-config update -g MyResourceGroup --cluster-name MyCluster -n myalerts --action-group-id /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyResourceGroup/providers/Microsoft.Insights/actionGroups/otherag + - name: Clear the notification target + text: az aks alert-config update -g MyResourceGroup --cluster-name MyCluster -n myalerts --action-group-id "" +""" + +helps['aks alert-config delete'] = """ + type: command + short-summary: Delete an alert configuration from a managed cluster. + parameters: + - name: --cluster-name + type: string + short-summary: Name of the managed cluster. + - name: --name -n + type: string + short-summary: Name of the alert configuration to delete. + - name: --aks-custom-headers + type: string + short-summary: Send custom headers. When specified, format should be Key1=Value1,Key2=Value2 + examples: + - name: Delete an alert configuration + text: az aks alert-config delete -g MyResourceGroup --cluster-name MyCluster -n myalerts +""" + +helps['aks alert-config list'] = """ + type: command + short-summary: List the alert configurations of a managed cluster. + parameters: + - name: --cluster-name + type: string + short-summary: Name of the managed cluster. + - name: --aks-custom-headers + type: string + short-summary: Send custom headers. When specified, format should be Key1=Value1,Key2=Value2 + examples: + - name: List all alert configurations in a cluster + text: az aks alert-config list -g MyResourceGroup --cluster-name MyCluster +""" + +helps['aks alert-config show'] = """ + type: command + short-summary: Show the details of an alert configuration. + parameters: + - name: --cluster-name + type: string + short-summary: Name of the managed cluster. + - name: --name -n + type: string + short-summary: Name of the alert configuration to show. + - name: --aks-custom-headers + type: string + short-summary: Send custom headers. When specified, format should be Key1=Value1,Key2=Value2 + examples: + - name: Show an alert configuration + text: az aks alert-config show -g MyResourceGroup --cluster-name MyCluster -n myalerts +""" + helps['aks prepared-image-specification'] = """ type: group short-summary: Commands to manage prepared image specifications. diff --git a/src/aks-preview/azext_aks_preview/_params.py b/src/aks-preview/azext_aks_preview/_params.py index b3fdaf738ec..f9220db6fb6 100644 --- a/src/aks-preview/azext_aks_preview/_params.py +++ b/src/aks-preview/azext_aks_preview/_params.py @@ -37,6 +37,7 @@ from azext_aks_preview._validators import ( validate_nat_gateway_managed_outbound_ipv6_count, validate_nat_gateway_v2_params, + validate_action_group_id, ) from azext_aks_preview._client_factory import CUSTOM_MGMT_AKS_PREVIEW from azext_aks_preview._completers import ( @@ -3688,6 +3689,36 @@ def load_arguments(self, _): "'matchExpressions'. Maximum 100 entries.", ) + # AKS alert configuration commands + with self.argument_context("aks alert-config") as c: + c.argument("cluster_name", help="The cluster name.") + c.argument( + "aks_custom_headers", + help="Send custom headers. When specified, format should be Key1=Value1,Key2=Value2.", + ) + + for scope in ['aks alert-config add', + 'aks alert-config update', + 'aks alert-config delete', + 'aks alert-config show']: + with self.argument_context(scope) as c: + c.argument('name', options_list=['--name', '-n'], required=True, + help='Name of the alert configuration.') + + for scope in ['aks alert-config add', + 'aks alert-config update']: + with self.argument_context(scope) as c: + c.argument('mode', arg_type=get_enum_type(['Disabled', 'Managed']), + help='How AKS manages alerts for the cluster.') + c.argument('action_group_id', options_list=['--action-group-id'], + validator=validate_action_group_id, + help='Resource ID of the Azure Monitor action group to send ' + 'notifications to. Pass an empty string to clear it.') + + with self.argument_context('aks alert-config add') as c: + c.argument('mode', arg_type=get_enum_type(['Disabled', 'Managed']), required=True, + help='How AKS manages alerts for the cluster.') + # aks list-vm-skus command with self.argument_context("aks list-vm-skus") as c: c.argument( diff --git a/src/aks-preview/azext_aks_preview/_validators.py b/src/aks-preview/azext_aks_preview/_validators.py index b84add3f8bb..505fd3acea7 100644 --- a/src/aks-preview/azext_aks_preview/_validators.py +++ b/src/aks-preview/azext_aks_preview/_validators.py @@ -1270,3 +1270,26 @@ def validate_prepared_image_specification_id(namespace): "--prepared-image-specification-id must be a resource ID of type " "Microsoft.ContainerService/preparedImageSpecifications/versions." ) + + +def validate_action_group_id(namespace): + """Validate that --action-group-id refers to a Microsoft.Insights/actionGroups resource. + + An unset or empty value is allowed: the RP accepts an empty actionGroupId, and the CLI + always sends the key so the required-property contract is satisfied. + """ + action_group_id = getattr(namespace, "action_group_id", None) + if not action_group_id: + return + if not is_valid_resource_id(action_group_id): + raise InvalidArgumentValueError( + f"--action-group-id is not a valid Azure resource ID: {action_group_id}" + ) + parsed = parse_resource_id(action_group_id) + provider_namespace = (parsed.get("namespace") or "").lower() + resource_type = (parsed.get("type") or "").lower() + if provider_namespace != "microsoft.insights" or resource_type != "actiongroups": + raise InvalidArgumentValueError( + "--action-group-id must reference a Microsoft.Insights/actionGroups resource, " + f"got: {action_group_id}" + ) diff --git a/src/aks-preview/azext_aks_preview/alertconfiguration.py b/src/aks-preview/azext_aks_preview/alertconfiguration.py new file mode 100644 index 00000000000..da870bc4a97 --- /dev/null +++ b/src/aks-preview/azext_aks_preview/alertconfiguration.py @@ -0,0 +1,106 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core.azclierror import ( + RequiredArgumentMissingError, + ResourceNotFoundError, +) +from azure.cli.core.util import sdk_no_wait +from azure.core.exceptions import ResourceNotFoundError as SdkResourceNotFoundError + +from azext_aks_preview.vendored_sdks.azure_mgmt_preview_aks.models import ( + AlertConfiguration, + AlertConfigurationProperties, + AlertNotification, +) + + +def aks_alert_config_add_internal(cmd, client, raw_parameters, headers, no_wait): # pylint: disable=unused-argument + resource_group_name = raw_parameters.get("resource_group_name") + cluster_name = raw_parameters.get("cluster_name") + name = raw_parameters.get("name") + mode = raw_parameters.get("mode") + action_group_id = raw_parameters.get("action_group_id") + + if not name: + raise RequiredArgumentMissingError( + "Please specify --name for the alert configuration." + ) + if not mode: + raise RequiredArgumentMissingError( + "Please specify --mode for the alert configuration. " + "Allowed values are 'Managed' and 'Disabled'." + ) + + alert_config = AlertConfiguration( + properties=AlertConfigurationProperties( + mode=mode, + notification=AlertNotification(action_group_id=action_group_id or ""), + ) + ) + + return sdk_no_wait( + no_wait, + client.begin_create_or_update, + resource_group_name, + cluster_name, + name, + alert_config, + headers=headers, + ) + + +def aks_alert_config_update_internal(cmd, client, raw_parameters, headers, no_wait): # pylint: disable=unused-argument + resource_group_name = raw_parameters.get("resource_group_name") + cluster_name = raw_parameters.get("cluster_name") + name = raw_parameters.get("name") + mode = raw_parameters.get("mode") + action_group_id = raw_parameters.get("action_group_id") + + if not name: + raise RequiredArgumentMissingError( + "Please specify --name for the alert configuration." + ) + # An empty string is a meaningful value for --action-group-id (it clears the action + # group), so distinguish "not supplied" (None) from "supplied as empty". + if mode is None and action_group_id is None: + raise RequiredArgumentMissingError( + "Please specify at least one of --mode or --action-group-id to update." + ) + + try: + existing = client.get(resource_group_name, cluster_name, name, headers=headers) + except SdkResourceNotFoundError as ex: + raise ResourceNotFoundError( + f"Alert configuration '{name}' was not found in cluster '{cluster_name}'. " + "Use 'az aks alert-config add' to create it." + ) from ex + + existing_properties = getattr(existing, "properties", None) + existing_mode = getattr(existing_properties, "mode", None) + existing_notification = getattr(existing_properties, "notification", None) + existing_action_group_id = getattr(existing_notification, "action_group_id", None) + + merged_mode = mode if mode is not None else existing_mode + merged_action_group_id = ( + action_group_id if action_group_id is not None else existing_action_group_id + ) + + alert_config = AlertConfiguration( + properties=AlertConfigurationProperties( + mode=merged_mode, + notification=AlertNotification(action_group_id=merged_action_group_id or ""), + ) + ) + + return sdk_no_wait( + no_wait, + client.begin_create_or_update, + resource_group_name, + cluster_name, + name, + alert_config, + headers=headers, + ) diff --git a/src/aks-preview/azext_aks_preview/commands.py b/src/aks-preview/azext_aks_preview/commands.py index 1472d487bdf..bca0f667c5e 100644 --- a/src/aks-preview/azext_aks_preview/commands.py +++ b/src/aks-preview/azext_aks_preview/commands.py @@ -20,6 +20,7 @@ cf_jwt_authenticators, cf_vm_skus, cf_prepared_image_specifications, + cf_alert_configurations, ) from azext_aks_preview._format import ( @@ -54,6 +55,8 @@ aks_jwtauthenticator_list_table_format, aks_jwtauthenticator_show_table_format, aks_list_vm_skus_table_format, + aks_alert_config_list_table_format, + aks_alert_config_show_table_format, ) from knack.log import get_logger @@ -173,6 +176,12 @@ def load_command_table(self, _): client_factory=cf_prepared_image_specifications, ) + alert_configurations_sdk = CliCommandType( + operations_tmpl="azext_aks_preview.vendored_sdks.azure_mgmt_preview_aks." + "operations._operations#AlertConfigurationsOperations.{}", + client_factory=cf_alert_configurations, + ) + # AKS managed cluster commands with self.command_group( "aks", @@ -642,6 +651,36 @@ def load_command_table(self, _): table_transformer=aks_jwtauthenticator_show_table_format ) + # AKS alert configuration commands + with self.command_group( + "aks alert-config", alert_configurations_sdk, client_factory=cf_alert_configurations, + ) as g: + g.custom_command( + "add", + "aks_alert_config_add", + supports_no_wait=True + ) + g.custom_command( + "update", + "aks_alert_config_update", + supports_no_wait=True + ) + g.custom_command( + "delete", + "aks_alert_config_delete", + supports_no_wait=True, confirmation=True + ) + g.custom_command( + "list", + "aks_alert_config_list", + table_transformer=aks_alert_config_list_table_format + ) + g.custom_show_command( + "show", + "aks_alert_config_show", + table_transformer=aks_alert_config_show_table_format + ) + # AKS list-vm-skus command with self.command_group( "aks", vm_skus_sdk, client_factory=cf_vm_skus diff --git a/src/aks-preview/azext_aks_preview/custom.py b/src/aks-preview/azext_aks_preview/custom.py index 95a15d66313..9311fa91239 100644 --- a/src/aks-preview/azext_aks_preview/custom.py +++ b/src/aks-preview/azext_aks_preview/custom.py @@ -131,6 +131,10 @@ add_machine, update_machine, ) +from azext_aks_preview.alertconfiguration import ( + aks_alert_config_add_internal, + aks_alert_config_update_internal, +) from azext_aks_preview.jwtauthenticator import ( aks_jwtauthenticator_add_internal, aks_jwtauthenticator_update_internal, @@ -6138,3 +6142,90 @@ def aks_prepared_image_specification_version_show(cmd, client, resource_group_na def aks_prepared_image_specification_version_list(cmd, client, resource_group_name, pis_name): return client.list_versions(resource_group_name, pis_name) + + +# Alert configuration commands +def aks_alert_config_add( + cmd, + client, + resource_group_name, + cluster_name, + name, + mode=None, + action_group_id=None, + aks_custom_headers=None, + no_wait=False +): + headers = get_aks_custom_headers(aks_custom_headers) + existing_alert_config = None + try: + existing_alert_config = client.get(resource_group_name, cluster_name, name, headers=headers) + except ResourceNotFoundError: + pass + + if existing_alert_config: + raise ClientRequestError( + f"Alert configuration '{name}' already exists. " + "Please use 'az aks alert-config update' to update it." + ) + + raw_parameters = locals() + return aks_alert_config_add_internal( + cmd, + client, + raw_parameters, + headers, + no_wait, + ) + + +def aks_alert_config_update( + cmd, + client, + resource_group_name, + cluster_name, + name, + mode=None, + action_group_id=None, + aks_custom_headers=None, + no_wait=False +): + headers = get_aks_custom_headers(aks_custom_headers) + raw_parameters = locals() + return aks_alert_config_update_internal( + cmd, + client, + raw_parameters, + headers, + no_wait, + ) + + +def aks_alert_config_delete( + cmd, + client, + resource_group_name, + cluster_name, + name, + aks_custom_headers=None, + no_wait=False +): + headers = get_aks_custom_headers(aks_custom_headers) + return sdk_no_wait( + no_wait, + client.begin_delete, + resource_group_name, + cluster_name, + name, + headers=headers, + ) + + +def aks_alert_config_list(cmd, client, resource_group_name, cluster_name, aks_custom_headers=None): + headers = get_aks_custom_headers(aks_custom_headers) + return client.list_by_managed_cluster(resource_group_name, cluster_name, headers=headers) + + +def aks_alert_config_show(cmd, client, resource_group_name, cluster_name, name, aks_custom_headers=None): + headers = get_aks_custom_headers(aks_custom_headers) + return client.get(resource_group_name, cluster_name, name, headers=headers) diff --git a/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py b/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py index 7da7157acba..5abaa77b06e 100644 --- a/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_aks_commands.py @@ -25239,6 +25239,106 @@ def test_aks_approuting_gateway_istio_enable_disable( ], ) + @AllowLargeResponse() + @AKSCustomResourceGroupPreparer( + random_name_length=17, name_prefix="clitest", location="eastus2euap", + preserve_default_location=True, + ) + # live_only: the recording toolchain works through the internal package proxy, but the + # alert configuration operation currently fails in the RP with ListLoadBalancerError / + # InvalidAuthenticationTokenTenant after the cluster is created. Once the RP no longer + # uses a token from the wrong tenant for the managed resource group lookup, record this + # scenario and drop the marker. + @live_only() + def test_aks_alert_config_cmds(self, resource_group, resource_group_location): + # The alertConfigurations RP endpoint is only deployed to selected regions, so this + # test pins its location and opts out of the AZURE_CLI_TEST_DEV_RESOURCE_GROUP_LOCATION + # override. + # reset the count so that in replay mode the random names will start with 0 + self.test_resources_count = 0 + aks_name = self.create_random_name('cliakstest', 16) + alert_config_name = self.create_random_name('alert', 10) + action_group_name = self.create_random_name('cliag', 12) + + self.kwargs.update({ + 'resource_group': resource_group, + 'name': aks_name, + 'alert_config_name': alert_config_name, + 'action_group_name': action_group_name, + 'location': resource_group_location, + 'ssh_key_value': self.generate_ssh_keys(), + }) + + action_group = self.cmd( + 'monitor action-group create --resource-group={resource_group} ' + '--name={action_group_name} --short-name clitest' + ).get_output_in_json() + self.kwargs.update({'action_group_id': action_group['id']}) + + create_cmd = ( + "aks create --resource-group={resource_group} --name={name} --location={location} " + "--node-count 1 --enable-managed-identity --ssh-key-value={ssh_key_value} " + ) + self.cmd(create_cmd, checks=[ + self.check('provisioningState', 'Succeeded'), + ]) + + add_cmd = ( + "aks alert-config add --resource-group={resource_group} --cluster-name={name} " + "--name={alert_config_name} --mode Managed --action-group-id={action_group_id} " + ) + self.cmd(add_cmd, checks=[ + self.check('name', '{alert_config_name}'), + self.check('properties.provisioningState', 'Succeeded'), + self.check('properties.mode', 'Managed'), + self.check('properties.notification.actionGroupId', '{action_group_id}'), + ]) + + self.cmd( + "aks alert-config show --resource-group={resource_group} --cluster-name={name} " + "--name={alert_config_name} ", + checks=[ + self.check('name', '{alert_config_name}'), + self.check('properties.mode', 'Managed'), + self.check('properties.notification.actionGroupId', '{action_group_id}'), + ], + ) + + self.cmd( + "aks alert-config list --resource-group={resource_group} --cluster-name={name} ", + checks=[ + self.check('length(@)', 1), + self.check('[0].name', '{alert_config_name}'), + ], + ) + + # Update only the mode; the action group must be preserved by read-modify-write. + self.cmd( + "aks alert-config update --resource-group={resource_group} --cluster-name={name} " + "--name={alert_config_name} --mode Disabled ", + checks=[ + self.check('properties.provisioningState', 'Succeeded'), + self.check('properties.mode', 'Disabled'), + self.check('properties.notification.actionGroupId', '{action_group_id}'), + ], + ) + + self.cmd( + "aks alert-config delete --resource-group={resource_group} --cluster-name={name} " + "--name={alert_config_name} --yes ", + checks=[self.is_empty()], + ) + + self.cmd( + "aks alert-config list --resource-group={resource_group} --cluster-name={name} ", + checks=[self.check('length(@)', 0)], + ) + + self.cmd( + "aks delete --resource-group={resource_group} --name={name} --yes --no-wait", + checks=[self.is_empty()], + ) + @AllowLargeResponse(8192) def test_aks_list_vm_skus(self): # Basic call: should return a non-empty list of SKUs for a well-known region. diff --git a/src/aks-preview/azext_aks_preview/tests/latest/test_alert_configuration.py b/src/aks-preview/azext_aks_preview/tests/latest/test_alert_configuration.py new file mode 100644 index 00000000000..2220826a674 --- /dev/null +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_alert_configuration.py @@ -0,0 +1,319 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock + +from azure.cli.core.azclierror import ( + ClientRequestError, + InvalidArgumentValueError, + RequiredArgumentMissingError, + ResourceNotFoundError, +) +from azure.core.exceptions import ResourceNotFoundError as SdkResourceNotFoundError + +from azext_aks_preview import custom as aks_custom +from azext_aks_preview.alertconfiguration import ( + aks_alert_config_add_internal, + aks_alert_config_update_internal, +) +from azext_aks_preview._format import ( + aks_alert_config_list_table_format, + aks_alert_config_show_table_format, +) +from azext_aks_preview._validators import validate_action_group_id + +VALID_ACTION_GROUP_ID = ( + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg" + "/providers/Microsoft.Insights/actionGroups/myag" +) + + +class TestValidateActionGroupId(unittest.TestCase): + def test_none_is_allowed(self): + namespace = SimpleNamespace(action_group_id=None) + validate_action_group_id(namespace) + + def test_empty_string_is_allowed(self): + namespace = SimpleNamespace(action_group_id="") + validate_action_group_id(namespace) + + def test_valid_action_group_id_is_allowed(self): + namespace = SimpleNamespace(action_group_id=VALID_ACTION_GROUP_ID) + validate_action_group_id(namespace) + + def test_valid_action_group_id_is_case_insensitive(self): + namespace = SimpleNamespace( + action_group_id=VALID_ACTION_GROUP_ID.replace( + "Microsoft.Insights/actionGroups", "microsoft.insights/actiongroups" + ) + ) + validate_action_group_id(namespace) + + def test_not_a_resource_id_is_rejected(self): + namespace = SimpleNamespace(action_group_id="myag") + with self.assertRaises(InvalidArgumentValueError): + validate_action_group_id(namespace) + + def test_wrong_resource_type_is_rejected(self): + namespace = SimpleNamespace( + action_group_id=( + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg" + "/providers/Microsoft.Storage/storageAccounts/mystorage" + ) + ) + with self.assertRaises(InvalidArgumentValueError): + validate_action_group_id(namespace) + + +class TestAlertConfigTableFormat(unittest.TestCase): + def _sample(self): + return { + "name": "myalerts", + "properties": { + "mode": "Managed", + "notification": {"actionGroupId": VALID_ACTION_GROUP_ID}, + "provisioningState": "Succeeded", + }, + } + + def test_show_format(self): + row = aks_alert_config_show_table_format(self._sample()) + self.assertEqual(row["name"], "myalerts") + self.assertEqual(row["mode"], "Managed") + self.assertEqual(row["actionGroupId"], VALID_ACTION_GROUP_ID) + self.assertEqual(row["provisioningState"], "Succeeded") + + def test_show_format_tolerates_missing_fields(self): + row = aks_alert_config_show_table_format({"name": "myalerts"}) + self.assertEqual(row["name"], "myalerts") + self.assertEqual(row["mode"], "") + self.assertEqual(row["actionGroupId"], "") + self.assertEqual(row["provisioningState"], "") + + def test_show_format_tolerates_null_notification(self): + row = aks_alert_config_show_table_format( + {"name": "myalerts", "properties": {"mode": "Disabled", "notification": None}} + ) + self.assertEqual(row["mode"], "Disabled") + self.assertEqual(row["actionGroupId"], "") + + def test_list_format(self): + rows = aks_alert_config_list_table_format([self._sample(), self._sample()]) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]["name"], "myalerts") + + +class TestAlertConfigAdd(unittest.TestCase): + def _client(self): + client = MagicMock() + client.begin_create_or_update.return_value = "poller" + return client + + def _sent_model(self, client): + # begin_create_or_update(rg, cluster, name, resource, headers=...) + return client.begin_create_or_update.call_args[0][3] + + def test_add_sends_mode_and_action_group(self): + client = self._client() + params = { + "resource_group_name": "rg", + "cluster_name": "cluster", + "name": "myalerts", + "mode": "Managed", + "action_group_id": VALID_ACTION_GROUP_ID, + } + aks_alert_config_add_internal(None, client, params, {}, False) + + args = client.begin_create_or_update.call_args[0] + self.assertEqual(args[0], "rg") + self.assertEqual(args[1], "cluster") + self.assertEqual(args[2], "myalerts") + model = self._sent_model(client) + self.assertEqual(model.properties.mode, "Managed") + self.assertEqual(model.properties.notification.action_group_id, VALID_ACTION_GROUP_ID) + + def test_add_without_action_group_sends_empty_string(self): + client = self._client() + params = { + "resource_group_name": "rg", + "cluster_name": "cluster", + "name": "myalerts", + "mode": "Disabled", + "action_group_id": None, + } + aks_alert_config_add_internal(None, client, params, {}, False) + + model = self._sent_model(client) + self.assertIsNotNone(model.properties.notification) + self.assertEqual(model.properties.notification.action_group_id, "") + + def test_add_without_name_raises(self): + params = { + "resource_group_name": "rg", + "cluster_name": "cluster", + "name": None, + "mode": "Managed", + "action_group_id": None, + } + with self.assertRaises(RequiredArgumentMissingError): + aks_alert_config_add_internal(None, self._client(), params, {}, False) + + def test_add_without_mode_raises(self): + params = { + "resource_group_name": "rg", + "cluster_name": "cluster", + "name": "myalerts", + "mode": None, + "action_group_id": None, + } + with self.assertRaises(RequiredArgumentMissingError): + aks_alert_config_add_internal(None, self._client(), params, {}, False) + + +class TestAlertConfigUpdate(unittest.TestCase): + def _client_with_existing(self, mode="Managed", action_group_id=VALID_ACTION_GROUP_ID): + client = MagicMock() + existing = MagicMock() + existing.properties.mode = mode + existing.properties.notification.action_group_id = action_group_id + client.get.return_value = existing + client.begin_create_or_update.return_value = "poller" + return client + + def _sent_model(self, client): + return client.begin_create_or_update.call_args[0][3] + + def test_update_mode_only_preserves_action_group(self): + client = self._client_with_existing() + params = { + "resource_group_name": "rg", + "cluster_name": "cluster", + "name": "myalerts", + "mode": "Disabled", + "action_group_id": None, + } + aks_alert_config_update_internal(None, client, params, {}, False) + + model = self._sent_model(client) + self.assertEqual(model.properties.mode, "Disabled") + self.assertEqual(model.properties.notification.action_group_id, VALID_ACTION_GROUP_ID) + + def test_update_action_group_only_preserves_mode(self): + client = self._client_with_existing() + new_id = VALID_ACTION_GROUP_ID.replace("myag", "otherag") + params = { + "resource_group_name": "rg", + "cluster_name": "cluster", + "name": "myalerts", + "mode": None, + "action_group_id": new_id, + } + aks_alert_config_update_internal(None, client, params, {}, False) + + model = self._sent_model(client) + self.assertEqual(model.properties.mode, "Managed") + self.assertEqual(model.properties.notification.action_group_id, new_id) + + def test_update_can_clear_action_group_with_empty_string(self): + client = self._client_with_existing() + params = { + "resource_group_name": "rg", + "cluster_name": "cluster", + "name": "myalerts", + "mode": None, + "action_group_id": "", + } + aks_alert_config_update_internal(None, client, params, {}, False) + + model = self._sent_model(client) + self.assertEqual(model.properties.notification.action_group_id, "") + + def test_update_with_no_updatable_flags_raises(self): + client = self._client_with_existing() + params = { + "resource_group_name": "rg", + "cluster_name": "cluster", + "name": "myalerts", + "mode": None, + "action_group_id": None, + } + with self.assertRaises(RequiredArgumentMissingError): + aks_alert_config_update_internal(None, client, params, {}, False) + client.begin_create_or_update.assert_not_called() + + def test_update_on_missing_config_raises_cli_not_found(self): + client = MagicMock() + client.get.side_effect = SdkResourceNotFoundError("nope") + params = { + "resource_group_name": "rg", + "cluster_name": "cluster", + "name": "myalerts", + "mode": "Disabled", + "action_group_id": None, + } + with self.assertRaises(ResourceNotFoundError): + aks_alert_config_update_internal(None, client, params, {}, False) + client.begin_create_or_update.assert_not_called() + + def test_update_without_name_raises(self): + params = { + "resource_group_name": "rg", + "cluster_name": "cluster", + "name": None, + "mode": "Disabled", + "action_group_id": None, + } + with self.assertRaises(RequiredArgumentMissingError): + aks_alert_config_update_internal(None, MagicMock(), params, {}, False) + + +class TestAlertConfigCustomWrappers(unittest.TestCase): + def test_delete_calls_begin_delete(self): + client = MagicMock() + aks_custom.aks_alert_config_delete( + None, client, "rg", "cluster", "myalerts", aks_custom_headers=None, no_wait=False + ) + args = client.begin_delete.call_args[0] + self.assertEqual(args[0], "rg") + self.assertEqual(args[1], "cluster") + self.assertEqual(args[2], "myalerts") + + def test_list_calls_list_by_managed_cluster(self): + client = MagicMock() + aks_custom.aks_alert_config_list(None, client, "rg", "cluster") + args = client.list_by_managed_cluster.call_args[0] + self.assertEqual(args[0], "rg") + self.assertEqual(args[1], "cluster") + + def test_show_calls_get(self): + client = MagicMock() + aks_custom.aks_alert_config_show(None, client, "rg", "cluster", "myalerts") + args = client.get.call_args[0] + self.assertEqual(args[0], "rg") + self.assertEqual(args[1], "cluster") + self.assertEqual(args[2], "myalerts") + + def test_add_rejects_existing_config(self): + client = MagicMock() + client.get.return_value = MagicMock() # config already exists + with self.assertRaises(ClientRequestError): + aks_custom.aks_alert_config_add( + None, client, "rg", "cluster", "myalerts", mode="Managed" + ) + client.begin_create_or_update.assert_not_called() + + def test_add_proceeds_when_config_absent(self): + client = MagicMock() + client.get.side_effect = SdkResourceNotFoundError("nope") + aks_custom.aks_alert_config_add( + None, client, "rg", "cluster", "myalerts", mode="Managed" + ) + client.begin_create_or_update.assert_called_once() + + +if __name__ == "__main__": + unittest.main()