From 0771b354cb294887f462ca227100f6836204ea2d Mon Sep 17 00:00:00 2001 From: Rob Pickerill Date: Tue, 20 Aug 2024 22:38:47 +0100 Subject: [PATCH] add ignoreNullValues for AWS CloudWatch Scaler (#5635) * add errorWhenMetricValueEmpty Signed-off-by: Rob Pickerill * fix golangci-lint Signed-off-by: Rob Pickerill * improve error message for empty results Signed-off-by: Rob Pickerill * add error when empty metric values to changelog Signed-off-by: Rob Pickerill * rename errorWhenMetricValuesEmpty -> errorWhenNullValues Signed-off-by: Rob Pickerill * use getParameterFromConfigV2 to read config for errorWhenNullValues Signed-off-by: Rob Pickerill * add e2e for error state for cw, and improve e2e for min values for cw Signed-off-by: Rob Pickerill * remove erroneous print statement Signed-off-by: Rob Pickerill * remove unused vars Signed-off-by: Rob Pickerill * rename errorWhenMetricValuesEmpty -> ignoreNullValues Signed-off-by: Rob Pickerill * move towards shared package for e2e aws Signed-off-by: Rob Pickerill * minMetricValue optionality based on ignoreNullValues Signed-off-by: Rob Pickerill * fail fast Co-authored-by: Jorge Turrado Ferrero Signed-off-by: Rob Pickerill * Update tests/scalers/aws/aws_cloudwatch_ignore_null_values_false/aws_cloudwatch_ignore_null_values_false_test.go Co-authored-by: Jorge Turrado Ferrero Signed-off-by: Rob Pickerill * Apply suggestions from code review Co-authored-by: Jorge Turrado Ferrero Signed-off-by: Rob Pickerill * fail fast Signed-off-by: Rob Pickerill * fix broken new line Signed-off-by: Rob Pickerill * fix broken new lines Signed-off-by: Rob Pickerill * assert no scaling changes in e2e, and set false for required in minMetricValue Signed-off-by: robpickerill * fix ci checks Signed-off-by: robpickerill * Update tests/scalers/aws/aws_cloudwatch_min_metric_value/aws_cloudwatch_min_metric_value_test.go fix invalid check Co-authored-by: Jorge Turrado Ferrero Signed-off-by: Rob Pickerill * fix merge conflicts Signed-off-by: robpickerill * fix e2e package names Signed-off-by: robpickerill --------- Signed-off-by: Rob Pickerill Signed-off-by: robpickerill Co-authored-by: Jorge Turrado Ferrero --- CHANGELOG.md | 5 +- pkg/scalers/aws_cloudwatch_scaler.go | 14 +- pkg/scalers/aws_cloudwatch_scaler_test.go | 655 +++++++++++------- tests/helper/helper.go | 32 +- ...loudwatch_ignore_null_values_false_test.go | 190 +++++ .../aws_cloudwatch_min_metric_value_test.go | 199 ++++++ .../aws/helpers/cloudwatch/cloudwatch.go | 70 ++ 7 files changed, 889 insertions(+), 276 deletions(-) create mode 100644 tests/scalers/aws/aws_cloudwatch_ignore_null_values_false/aws_cloudwatch_ignore_null_values_false_test.go create mode 100644 tests/scalers/aws/aws_cloudwatch_min_metric_value/aws_cloudwatch_min_metric_value_test.go create mode 100644 tests/scalers/aws/helpers/cloudwatch/cloudwatch.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ef88e5827..928a8128c72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,10 @@ Here is an overview of all new **experimental** features: ### Improvements -- TODO ([#XXX](https://github.com/kedacore/keda/issues/XXX)) +- **AWS CloudWatch Scaler**: Add support for ignoreNullValues ([#5352](https://github.com/kedacore/keda/issues/5352)) +- **GCP Scalers**: Added custom time horizon in GCP scalers ([#5778](https://github.com/kedacore/keda/issues/5778)) +- **GitHub Scaler**: Fixed pagination, fetching repository list ([#5738](https://github.com/kedacore/keda/issues/5738)) +- **Kafka**: Fix logic to scale to zero on invalid offset even with earliest offsetResetPolicy ([#5689](https://github.com/kedacore/keda/issues/5689)) ### Fixes diff --git a/pkg/scalers/aws_cloudwatch_scaler.go b/pkg/scalers/aws_cloudwatch_scaler.go index cd12a91976d..a07db246f80 100644 --- a/pkg/scalers/aws_cloudwatch_scaler.go +++ b/pkg/scalers/aws_cloudwatch_scaler.go @@ -37,6 +37,7 @@ type awsCloudwatchMetadata struct { TargetMetricValue float64 `keda:"name=targetMetricValue, order=triggerMetadata"` ActivationTargetMetricValue float64 `keda:"name=activationTargetMetricValue, order=triggerMetadata, optional"` MinMetricValue float64 `keda:"name=minMetricValue, order=triggerMetadata"` + IgnoreNullValues bool `keda:"name=ignoreNullValues, order=triggerMetadata, optional, default=true"` MetricCollectionTime int64 `keda:"name=metricCollectionTime, order=triggerMetadata, optional, default=300"` MetricStat string `keda:"name=metricStat, order=triggerMetadata, optional, default=Average"` @@ -191,7 +192,6 @@ func computeQueryWindow(current time.Time, metricPeriodSec, metricEndTimeOffsetS func (s *awsCloudwatchScaler) GetMetricsAndActivity(ctx context.Context, metricName string) ([]external_metrics.ExternalMetricValue, bool, error) { metricValue, err := s.GetCloudwatchMetrics(ctx) - if err != nil { s.logger.Error(err, "Error getting metric value") return []external_metrics.ExternalMetricValue{}, false, err @@ -274,20 +274,28 @@ func (s *awsCloudwatchScaler) GetCloudwatchMetrics(ctx context.Context) (float64 } output, err := s.cwClient.GetMetricData(ctx, &input) - if err != nil { s.logger.Error(err, "Failed to get output") return -1, err } s.logger.V(1).Info("Received Metric Data", "data", output) + + // If no metric data results or the first result has no values, and ignoreNullValues is false, + // the scaler should return an error to prevent any further scaling actions. + if len(output.MetricDataResults) > 0 && len(output.MetricDataResults[0].Values) == 0 && !s.metadata.IgnoreNullValues { + emptyMetricsErrMsg := "empty metric data received, ignoreNullValues is false, returning error" + s.logger.Error(nil, emptyMetricsErrMsg) + return -1, fmt.Errorf(emptyMetricsErrMsg) + } + var metricValue float64 + if len(output.MetricDataResults) > 0 && len(output.MetricDataResults[0].Values) > 0 { metricValue = output.MetricDataResults[0].Values[0] } else { s.logger.Info("empty metric data received, returning minMetricValue") metricValue = s.metadata.MinMetricValue } - return metricValue, nil } diff --git a/pkg/scalers/aws_cloudwatch_scaler_test.go b/pkg/scalers/aws_cloudwatch_scaler_test.go index 35290154e80..da6e85fc2c7 100644 --- a/pkg/scalers/aws_cloudwatch_scaler_test.go +++ b/pkg/scalers/aws_cloudwatch_scaler_test.go @@ -22,6 +22,7 @@ const ( testAWSCloudwatchSessionToken = "none" testAWSCloudwatchErrorMetric = "Error" testAWSCloudwatchNoValueMetric = "NoValue" + testAWSCloudwatchEmptyValues = "EmptyValues" ) var testAWSCloudwatchResolvedEnv = map[string]string{ @@ -50,312 +51,433 @@ type awsCloudwatchMetricIdentifier struct { var testAWSCloudwatchMetadata = []parseAWSCloudwatchMetadataTestData{ {map[string]string{}, testAWSAuthentication, true, "Empty structures"}, // properly formed cloudwatch query and awsRegion - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "activationTargetMetricValue": "0", - "minMetricValue": "0", - "awsRegion": "eu-west-1"}, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "activationTargetMetricValue": "0", + "minMetricValue": "0", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, false, - "properly formed cloudwatch query and awsRegion"}, + "properly formed cloudwatch query and awsRegion", + }, // properly formed cloudwatch expression query and awsRegion - {map[string]string{ - "namespace": "AWS/SQS", - "expression": "SELECT MIN(MessageCount) FROM \"AWS/AmazonMQ\" WHERE Broker = 'production' and Queue = 'worker'", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "activationTargetMetricValue": "0", - "minMetricValue": "0", - "awsRegion": "eu-west-1"}, + { + map[string]string{ + "namespace": "AWS/SQS", + "expression": "SELECT MIN(MessageCount) FROM \"AWS/AmazonMQ\" WHERE Broker = 'production' and Queue = 'worker'", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "activationTargetMetricValue": "0", + "minMetricValue": "0", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, false, - "properly formed cloudwatch expression query and awsRegion"}, + "properly formed cloudwatch expression query and awsRegion", + }, // Properly formed cloudwatch query with optional parameters - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "activationTargetMetricValue": "0", - "minMetricValue": "0", - "metricCollectionTime": "300", - "metricStat": "Average", - "metricStatPeriod": "300", - "awsRegion": "eu-west-1", - "awsEndpoint": "http://localhost:4566"}, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "activationTargetMetricValue": "0", + "minMetricValue": "0", + "metricCollectionTime": "300", + "metricStat": "Average", + "metricStatPeriod": "300", + "awsRegion": "eu-west-1", + "awsEndpoint": "http://localhost:4566", + }, testAWSAuthentication, false, - "Properly formed cloudwatch query with optional parameters"}, + "Properly formed cloudwatch query with optional parameters", + }, // properly formed cloudwatch query but Region is empty - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "activationTargetMetricValue": "0", - "minMetricValue": "0", - "awsRegion": ""}, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "activationTargetMetricValue": "0", + "minMetricValue": "0", + "awsRegion": "", + }, testAWSAuthentication, true, - "properly formed cloudwatch query but Region is empty"}, + "properly formed cloudwatch query but Region is empty", + }, // Missing namespace - {map[string]string{"dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "awsRegion": "eu-west-1"}, + { + map[string]string{ + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, true, - "Missing namespace"}, + "Missing namespace", + }, // Missing dimensionName - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "awsRegion": "eu-west-1"}, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, true, - "Missing dimensionName"}, + "Missing dimensionName", + }, // Missing dimensionValue - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "awsRegion": "eu-west-1"}, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, true, - "Missing dimensionValue"}, + "Missing dimensionValue", + }, // Missing metricName - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "targetMetricValue": "2", - "minMetricValue": "0", - "awsRegion": "eu-west-1"}, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "targetMetricValue": "2", + "minMetricValue": "0", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, true, - "Missing metricName"}, + "Missing metricName", + }, // with static "aws_credentials" from TriggerAuthentication - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricCollectionTime": "300", - "metricStat": "Average", - "metricStatPeriod": "300", - "awsRegion": "eu-west-1"}, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricCollectionTime": "300", + "metricStat": "Average", + "metricStatPeriod": "300", + "awsRegion": "eu-west-1", + }, map[string]string{ "awsAccessKeyId": testAWSCloudwatchAccessKeyID, "awsSecretAccessKey": testAWSCloudwatchSecretAccessKey, }, false, - "with AWS Credentials from TriggerAuthentication"}, + "with AWS Credentials from TriggerAuthentication", + }, // with temporary "aws_credentials" from TriggerAuthentication - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricCollectionTime": "300", - "metricStat": "Average", - "metricStatPeriod": "300", - "awsRegion": "eu-west-1"}, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricCollectionTime": "300", + "metricStat": "Average", + "metricStatPeriod": "300", + "awsRegion": "eu-west-1", + }, map[string]string{ "awsAccessKeyId": testAWSCloudwatchAccessKeyID, "awsSecretAccessKey": testAWSCloudwatchSecretAccessKey, "awsSessionToken": testAWSCloudwatchSessionToken, }, false, - "with AWS Credentials from TriggerAuthentication"}, + "with AWS Credentials from TriggerAuthentication", + }, // with "aws_role" from TriggerAuthentication - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricCollectionTime": "300", - "metricStat": "Average", - "metricStatPeriod": "300", - "awsRegion": "eu-west-1"}, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricCollectionTime": "300", + "metricStat": "Average", + "metricStatPeriod": "300", + "awsRegion": "eu-west-1", + }, map[string]string{ "awsRoleArn": testAWSCloudwatchRoleArn, }, false, - "with AWS Role from TriggerAuthentication"}, - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricCollectionTime": "300", - "metricStat": "Average", - "metricStatPeriod": "300", - "awsRegion": "eu-west-1", - "identityOwner": "operator"}, + "with AWS Role from TriggerAuthentication", + }, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricCollectionTime": "300", + "metricStat": "Average", + "metricStatPeriod": "300", + "awsRegion": "eu-west-1", + "identityOwner": "operator", + }, map[string]string{}, false, - "with AWS Role assigned on KEDA operator itself"}, - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricCollectionTime": "a", - "metricStat": "Average", - "metricStatPeriod": "300", - "awsRegion": "eu-west-1", - "identityOwner": "operator"}, + "with AWS Role assigned on KEDA operator itself", + }, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricCollectionTime": "a", + "metricStat": "Average", + "metricStatPeriod": "300", + "awsRegion": "eu-west-1", + "identityOwner": "operator", + }, map[string]string{}, true, - "if metricCollectionTime assigned with a string, need to be a number"}, - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricCollectionTime": "300", - "metricStat": "Average", - "metricStatPeriod": "a", - "awsRegion": "eu-west-1", - "identityOwner": "operator"}, + "if metricCollectionTime assigned with a string, need to be a number", + }, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricCollectionTime": "300", + "metricStat": "Average", + "metricStatPeriod": "a", + "awsRegion": "eu-west-1", + "identityOwner": "operator", + }, map[string]string{}, true, - "if metricStatPeriod assigned with a string, need to be a number"}, - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricStat": "Average", - "metricStatPeriod": "300", - "awsRegion": "eu-west-1"}, + "if metricStatPeriod assigned with a string, need to be a number", + }, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricStat": "Average", + "metricStatPeriod": "300", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, false, - "Missing metricCollectionTime not generate error because will get the default value"}, - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricCollectionTime": "300", - "metricStatPeriod": "300", - "awsRegion": "eu-west-1"}, + "Missing metricCollectionTime not generate error because will get the default value", + }, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricCollectionTime": "300", + "metricStatPeriod": "300", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, false, - "Missing metricStat not generate error because will get the default value"}, - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricCollectionTime": "300", - "metricStat": "Average", - "awsRegion": "eu-west-1"}, + "Missing metricStat not generate error because will get the default value", + }, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricCollectionTime": "300", + "metricStat": "Average", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, false, - "Missing metricStatPeriod not generate error because will get the default value"}, - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricStat": "Average", - "metricUnit": "Count", - "metricEndTimeOffset": "60", - "awsRegion": "eu-west-1"}, + "Missing metricStatPeriod not generate error because will get the default value", + }, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricStat": "Average", + "metricUnit": "Count", + "metricEndTimeOffset": "60", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, false, - "set a supported metricUnit"}, - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricCollectionTime": "300", - "metricStat": "SomeStat", - "awsRegion": "eu-west-1"}, + "set a supported metricUnit", + }, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricCollectionTime": "300", + "metricStat": "SomeStat", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, true, - "metricStat is not supported"}, - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricStatPeriod": "300", - "metricCollectionTime": "100", - "metricStat": "Average", - "awsRegion": "eu-west-1"}, + "metricStat is not supported", + }, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricStatPeriod": "300", + "metricCollectionTime": "100", + "metricStat": "Average", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, true, - "metricCollectionTime smaller than metricStatPeriod"}, - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricStatPeriod": "250", - "metricStat": "Average", - "awsRegion": "eu-west-1"}, + "metricCollectionTime smaller than metricStatPeriod", + }, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricStatPeriod": "250", + "metricStat": "Average", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, true, - "unsupported metricStatPeriod"}, - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricStatPeriod": "25", - "metricStat": "Average", - "awsRegion": "eu-west-1"}, + "unsupported metricStatPeriod", + }, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricStatPeriod": "25", + "metricStat": "Average", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, true, - "unsupported metricStatPeriod"}, - {map[string]string{ - "namespace": "AWS/SQS", - "dimensionName": "QueueName", - "dimensionValue": "keda", - "metricName": "ApproximateNumberOfMessagesVisible", - "targetMetricValue": "2", - "minMetricValue": "0", - "metricStatPeriod": "25", - "metricStat": "Average", - "metricUnit": "Hour", - "awsRegion": "eu-west-1"}, + "unsupported metricStatPeriod", + }, + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricStatPeriod": "25", + "metricStat": "Average", + "metricUnit": "Hour", + "awsRegion": "eu-west-1", + }, + testAWSAuthentication, true, + "unsupported metricUnit", + }, + // test ignoreNullValues is false + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricStatPeriod": "60", + "metricStat": "Average", + "ignoreNullValues": "false", + "awsRegion": "eu-west-1", + }, + testAWSAuthentication, false, + "with ignoreNullValues set to false", + }, + // test ignoreNullValues is true + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricStatPeriod": "60", + "metricStat": "Average", + "ignoreNullValues": "true", + "awsRegion": "eu-west-1", + }, + testAWSAuthentication, false, + "with ignoreNullValues set to true", + }, + // test ignoreNullValues is incorrect + { + map[string]string{ + "namespace": "AWS/SQS", + "dimensionName": "QueueName", + "dimensionValue": "keda", + "metricName": "ApproximateNumberOfMessagesVisible", + "targetMetricValue": "2", + "minMetricValue": "0", + "metricStatPeriod": "60", + "metricStat": "Average", + "ignoreNullValues": "maybe", + "awsRegion": "eu-west-1", + }, testAWSAuthentication, true, - "unsupported metricUnit"}, + "unsupported value for ignoreNullValues", + }, } var awsCloudwatchMetricIdentifiers = []awsCloudwatchMetricIdentifier{ @@ -397,6 +519,22 @@ var awsCloudwatchGetMetricTestData = []awsCloudwatchMetadata{ awsAuthorization: awsutils.AuthorizationMetadata{PodIdentityOwner: false}, triggerIndex: 0, }, + { + Namespace: "Custom", + MetricsName: "HasDataFromExpression", + Expression: "SELECT MIN(MessageCount) FROM \"AWS/AmazonMQ\" WHERE Broker = 'production' and Queue = 'worker'", + TargetMetricValue: 100, + MinMetricValue: 0, + MetricCollectionTime: 60, + MetricStat: "Average", + MetricUnit: "SampleCount", + MetricStatPeriod: 60, + MetricEndTimeOffset: 60, + AwsRegion: "us-west-2", + awsAuthorization: awsutils.AuthorizationMetadata{PodIdentityOwner: false}, + triggerIndex: 0, + }, + // Test for metric with no data, no error expected as we are ignoring null values { Namespace: "Custom", MetricsName: testAWSCloudwatchErrorMetric, @@ -413,6 +551,7 @@ var awsCloudwatchGetMetricTestData = []awsCloudwatchMetadata{ awsAuthorization: awsutils.AuthorizationMetadata{PodIdentityOwner: false}, triggerIndex: 0, }, + // Test for metric with no data, and the scaler errors when metric data values are empty { Namespace: "Custom", MetricsName: testAWSCloudwatchNoValueMetric, @@ -458,6 +597,14 @@ func (m *mockCloudwatch) GetMetricData(_ context.Context, input *cloudwatch.GetM return &cloudwatch.GetMetricDataOutput{ MetricDataResults: []types.MetricDataResult{}, }, nil + case testAWSCloudwatchEmptyValues: + return &cloudwatch.GetMetricDataOutput{ + MetricDataResults: []types.MetricDataResult{ + { + Values: []float64{}, + }, + }, + }, nil } } @@ -508,6 +655,12 @@ func TestAWSCloudwatchScalerGetMetrics(t *testing.T) { assert.Error(t, err, "expect error because of cloudwatch api error") case testAWSCloudwatchNoValueMetric: assert.NoError(t, err, "dont expect error when returning empty metric list from cloudwatch") + case testAWSCloudwatchEmptyValues: + if meta.IgnoreNullValues { + assert.NoError(t, err, "dont expect error when returning empty metric list from cloudwatch") + } else { + assert.Error(t, err, "expect error when returning empty metric list from cloudwatch, because ignoreNullValues is false") + } default: assert.EqualValues(t, int64(10.0), value[0].Value.Value()) } diff --git a/tests/helper/helper.go b/tests/helper/helper.go index ef653c1b12d..a21adb0c48f 100644 --- a/tests/helper/helper.go +++ b/tests/helper/helper.go @@ -307,18 +307,15 @@ func WaitForNamespaceDeletion(t *testing.T, nsName string) bool { return false } -func WaitForScaledJobCount(t *testing.T, kc *kubernetes.Clientset, scaledJobName, namespace string, - target, iterations, intervalSeconds int) bool { +func WaitForScaledJobCount(t *testing.T, kc *kubernetes.Clientset, scaledJobName, namespace string, target, iterations, intervalSeconds int) bool { return waitForJobCount(t, kc, fmt.Sprintf("scaledjob.keda.sh/name=%s", scaledJobName), namespace, target, iterations, intervalSeconds) } -func WaitForJobCount(t *testing.T, kc *kubernetes.Clientset, namespace string, - target, iterations, intervalSeconds int) bool { +func WaitForJobCount(t *testing.T, kc *kubernetes.Clientset, namespace string, target, iterations, intervalSeconds int) bool { return waitForJobCount(t, kc, "", namespace, target, iterations, intervalSeconds) } -func waitForJobCount(t *testing.T, kc *kubernetes.Clientset, selector, namespace string, - target, iterations, intervalSeconds int) bool { +func waitForJobCount(t *testing.T, kc *kubernetes.Clientset, selector, namespace string, target, iterations, intervalSeconds int) bool { for i := 0; i < iterations; i++ { jobList, _ := kc.BatchV1().Jobs(namespace).List(context.Background(), metav1.ListOptions{ LabelSelector: selector, @@ -338,9 +335,8 @@ func waitForJobCount(t *testing.T, kc *kubernetes.Clientset, selector, namespace return false } -func WaitForJobCountUntilIteration(t *testing.T, kc *kubernetes.Clientset, namespace string, - target, iterations, intervalSeconds int) bool { - var isTargetAchieved = false +func WaitForJobCountUntilIteration(t *testing.T, kc *kubernetes.Clientset, namespace string, target, iterations, intervalSeconds int) bool { + isTargetAchieved := false for i := 0; i < iterations; i++ { jobList, _ := kc.BatchV1().Jobs(namespace).List(context.Background(), metav1.ListOptions{}) @@ -362,8 +358,7 @@ func WaitForJobCountUntilIteration(t *testing.T, kc *kubernetes.Clientset, names } // Waits until deployment count hits target or number of iterations are done. -func WaitForPodCountInNamespace(t *testing.T, kc *kubernetes.Clientset, namespace string, - target, iterations, intervalSeconds int) bool { +func WaitForPodCountInNamespace(t *testing.T, kc *kubernetes.Clientset, namespace string, target, iterations, intervalSeconds int) bool { for i := 0; i < iterations; i++ { pods, _ := kc.CoreV1().Pods(namespace).List(context.Background(), metav1.ListOptions{}) @@ -408,8 +403,7 @@ func WaitForAllPodRunningInNamespace(t *testing.T, kc *kubernetes.Clientset, nam // Waits until the Horizontal Pod Autoscaler for the scaledObject reports that it has metrics available // to calculate, or until the number of iterations are done, whichever happens first. -func WaitForHPAMetricsToPopulate(t *testing.T, kc *kubernetes.Clientset, name, namespace string, - iterations, intervalSeconds int) bool { +func WaitForHPAMetricsToPopulate(t *testing.T, kc *kubernetes.Clientset, name, namespace string, iterations, intervalSeconds int) bool { totalWaitDuration := time.Duration(iterations) * time.Duration(intervalSeconds) * time.Second startedWaiting := time.Now() for i := 0; i < iterations; i++ { @@ -434,8 +428,7 @@ func WaitForHPAMetricsToPopulate(t *testing.T, kc *kubernetes.Clientset, name, n } // Waits until deployment ready replica count hits target or number of iterations are done. -func WaitForDeploymentReplicaReadyCount(t *testing.T, kc *kubernetes.Clientset, name, namespace string, - target, iterations, intervalSeconds int) bool { +func WaitForDeploymentReplicaReadyCount(t *testing.T, kc *kubernetes.Clientset, name, namespace string, target, iterations, intervalSeconds int) bool { for i := 0; i < iterations; i++ { deployment, _ := kc.AppsV1().Deployments(namespace).Get(context.Background(), name, metav1.GetOptions{}) replicas := deployment.Status.ReadyReplicas @@ -454,8 +447,7 @@ func WaitForDeploymentReplicaReadyCount(t *testing.T, kc *kubernetes.Clientset, } // Waits until statefulset count hits target or number of iterations are done. -func WaitForStatefulsetReplicaReadyCount(t *testing.T, kc *kubernetes.Clientset, name, namespace string, - target, iterations, intervalSeconds int) bool { +func WaitForStatefulsetReplicaReadyCount(t *testing.T, kc *kubernetes.Clientset, name, namespace string, target, iterations, intervalSeconds int) bool { for i := 0; i < iterations; i++ { statefulset, _ := kc.AppsV1().StatefulSets(namespace).Get(context.Background(), name, metav1.GetOptions{}) replicas := statefulset.Status.ReadyReplicas @@ -516,8 +508,7 @@ func AssertReplicaCountNotChangeDuringTimePeriod(t *testing.T, kc *kubernetes.Cl } } -func WaitForHpaCreation(t *testing.T, kc *kubernetes.Clientset, name, namespace string, - iterations, intervalSeconds int) (*autoscalingv2.HorizontalPodAutoscaler, error) { +func WaitForHpaCreation(t *testing.T, kc *kubernetes.Clientset, name, namespace string, iterations, intervalSeconds int) (*autoscalingv2.HorizontalPodAutoscaler, error) { hpa := &autoscalingv2.HorizontalPodAutoscaler{} var err error for i := 0; i < iterations; i++ { @@ -754,8 +745,7 @@ func DeletePodsInNamespaceBySelector(t *testing.T, kc *kubernetes.Clientset, sel } // Wait for Pods identified by selector to complete termination -func WaitForPodsTerminated(t *testing.T, kc *kubernetes.Clientset, selector, namespace string, - iterations, intervalSeconds int) bool { +func WaitForPodsTerminated(t *testing.T, kc *kubernetes.Clientset, selector, namespace string, iterations, intervalSeconds int) bool { for i := 0; i < iterations; i++ { pods, err := kc.CoreV1().Pods(namespace).List(context.Background(), metav1.ListOptions{LabelSelector: selector}) if (err != nil && errors.IsNotFound(err)) || len(pods.Items) == 0 { diff --git a/tests/scalers/aws/aws_cloudwatch_ignore_null_values_false/aws_cloudwatch_ignore_null_values_false_test.go b/tests/scalers/aws/aws_cloudwatch_ignore_null_values_false/aws_cloudwatch_ignore_null_values_false_test.go new file mode 100644 index 00000000000..ddf4e1f08e3 --- /dev/null +++ b/tests/scalers/aws/aws_cloudwatch_ignore_null_values_false/aws_cloudwatch_ignore_null_values_false_test.go @@ -0,0 +1,190 @@ +//go:build e2e +// +build e2e + +package aws_cloudwatch_ignore_null_values_false_test + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "testing" + + "github.com/joho/godotenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + . "github.com/kedacore/keda/v2/tests/helper" + "github.com/kedacore/keda/v2/tests/scalers/aws/helpers/cloudwatch" +) + +// Load environment variables from .env file +var _ = godotenv.Load("../../../.env") + +const ( + testName = "aws-cloudwatch-ignore-null-values-false-test" +) + +type templateData struct { + TestNamespace string + DeploymentName string + ScaledObjectName string + SecretName string + AwsAccessKeyID string + AwsSecretAccessKey string + AwsRegion string + CloudWatchMetricName string + CloudWatchMetricNamespace string + CloudWatchMetricDimensionName string + CloudWatchMetricDimensionValue string +} + +const ( + secretTemplate = `apiVersion: v1 +kind: Secret +metadata: + name: {{.SecretName}} + namespace: {{.TestNamespace}} +data: + AWS_ACCESS_KEY_ID: {{.AwsAccessKeyID}} + AWS_SECRET_ACCESS_KEY: {{.AwsSecretAccessKey}} +` + + triggerAuthenticationTemplate = `apiVersion: keda.sh/v1alpha1 +kind: TriggerAuthentication +metadata: + name: keda-trigger-auth-aws-credentials + namespace: {{.TestNamespace}} +spec: + secretTargetRef: + - parameter: awsAccessKeyID # Required. + name: {{.SecretName}} # Required. + key: AWS_ACCESS_KEY_ID # Required. + - parameter: awsSecretAccessKey # Required. + name: {{.SecretName}} # Required. + key: AWS_SECRET_ACCESS_KEY # Required. +` + + deploymentTemplate = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{.DeploymentName}} + namespace: {{.TestNamespace}} + labels: + app: {{.DeploymentName}} +spec: + replicas: 0 + selector: + matchLabels: + app: {{.DeploymentName}} + template: + metadata: + labels: + app: {{.DeploymentName}} + spec: + containers: + - name: nginx + image: nginxinc/nginx-unprivileged + ports: + - containerPort: 80 +` + + scaledObjectTemplate = ` +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: {{.ScaledObjectName}} + namespace: {{.TestNamespace}} + labels: + app: {{.DeploymentName}} +spec: + scaleTargetRef: + name: {{.DeploymentName}} + maxReplicaCount: 2 + minReplicaCount: 0 + cooldownPeriod: 1 + triggers: + - type: aws-cloudwatch + authenticationRef: + name: keda-trigger-auth-aws-credentials + metadata: + awsRegion: {{.AwsRegion}} + namespace: {{.CloudWatchMetricNamespace}} + dimensionName: {{.CloudWatchMetricDimensionName}} + dimensionValue: {{.CloudWatchMetricDimensionValue}} + metricName: {{.CloudWatchMetricName}} + targetMetricValue: "1" + minMetricValue: "1" + ignoreNullValues: "false" + metricCollectionTime: "120" + metricStatPeriod: "60" +` +) + +var ( + testNamespace = fmt.Sprintf("%s-ns", testName) + deploymentName = fmt.Sprintf("%s-deployment", testName) + scaledObjectName = fmt.Sprintf("%s-so", testName) + secretName = fmt.Sprintf("%s-secret", testName) + cloudwatchMetricName = fmt.Sprintf("cw-%d", GetRandomNumber()) + awsAccessKeyID = os.Getenv("TF_AWS_ACCESS_KEY") + awsSecretAccessKey = os.Getenv("TF_AWS_SECRET_KEY") + awsRegion = os.Getenv("TF_AWS_REGION") + cloudwatchMetricNamespace = "DoesNotExist" + cloudwatchMetricDimensionName = "dimensionName" + cloudwatchMetricDimensionValue = "dimensionValue" + minReplicaCount = 0 +) + +// This test is to verify that the scaler results in an error state when +// the metric query returns null values and the ignoreNullValues is set to false. +func TestCloudWatchScalerWithIgnoreNullValuesFalse(t *testing.T) { + ctx := context.Background() + + // setup cloudwatch + cloudwatchClient, err := cloudwatch.NewClient(ctx, awsRegion, awsAccessKeyID, awsSecretAccessKey, "") + require.Nil(t, err, "error creating cloudwatch client") + + // check that the metric in question is not already present, and is returning + // an empty set of values. + metricQuery := cloudwatch.CreateMetricDataInputForEmptyMetricValues(cloudwatchMetricNamespace, cloudwatchMetricName, cloudwatchMetricDimensionName, cloudwatchMetricDimensionValue) + metricData, err := cloudwatch.GetMetricData(ctx, cloudwatchClient, metricQuery) + require.Nil(t, err, "error getting metric data") + require.Nil(t, cloudwatch.ExpectEmptyMetricDataResults(metricData), "metric data should be empty") + + // Create kubernetes resources + kc := GetKubernetesClient(t) + data, templates := getTemplateData() + CreateKubernetesResources(t, kc, testNamespace, data, templates) + defer DeleteKubernetesResources(t, testNamespace, data, templates) + + assert.True(t, WaitForDeploymentReplicaReadyCount(t, kc, deploymentName, testNamespace, minReplicaCount, 60, 1), + "replica count should be %d after 1 minute", minReplicaCount) + + // check that the deployment did not scale, as the metric query is returning + // null values and the scaledobject is receiving errors, the deployment + // should not scale, even though the minMetricValue is set to 1. + AssertReplicaCountNotChangeDuringTimePeriod(t, kc, deploymentName, testNamespace, minReplicaCount, 60) +} + +func getTemplateData() (templateData, []Template) { + return templateData{ + TestNamespace: testNamespace, + DeploymentName: deploymentName, + ScaledObjectName: scaledObjectName, + SecretName: secretName, + AwsAccessKeyID: base64.StdEncoding.EncodeToString([]byte(awsAccessKeyID)), + AwsSecretAccessKey: base64.StdEncoding.EncodeToString([]byte(awsSecretAccessKey)), + AwsRegion: awsRegion, + CloudWatchMetricName: cloudwatchMetricName, + CloudWatchMetricNamespace: cloudwatchMetricNamespace, + CloudWatchMetricDimensionName: cloudwatchMetricDimensionName, + CloudWatchMetricDimensionValue: cloudwatchMetricDimensionValue, + }, []Template{ + {Name: "secretTemplate", Config: secretTemplate}, + {Name: "triggerAuthenticationTemplate", Config: triggerAuthenticationTemplate}, + {Name: "deploymentTemplate", Config: deploymentTemplate}, + {Name: "scaledObjectTemplate", Config: scaledObjectTemplate}, + } +} diff --git a/tests/scalers/aws/aws_cloudwatch_min_metric_value/aws_cloudwatch_min_metric_value_test.go b/tests/scalers/aws/aws_cloudwatch_min_metric_value/aws_cloudwatch_min_metric_value_test.go new file mode 100644 index 00000000000..341cead869c --- /dev/null +++ b/tests/scalers/aws/aws_cloudwatch_min_metric_value/aws_cloudwatch_min_metric_value_test.go @@ -0,0 +1,199 @@ +//go:build e2e +// +build e2e + +package aws_cloudwatch_min_metric_value_test + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "testing" + "time" + + "github.com/joho/godotenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + . "github.com/kedacore/keda/v2/tests/helper" + "github.com/kedacore/keda/v2/tests/scalers/aws/helpers/cloudwatch" +) + +// Load environment variables from .env file +var _ = godotenv.Load("../../../.env") + +const ( + testName = "aws-cloudwatch-min-metric-value-test" +) + +type templateData struct { + TestNamespace string + DeploymentName string + ScaledObjectName string + SecretName string + AwsAccessKeyID string + AwsSecretAccessKey string + AwsRegion string + CloudWatchMetricName string + CloudWatchMetricNamespace string + CloudWatchMetricDimensionName string + CloudWatchMetricDimensionValue string +} + +const ( + secretTemplate = `apiVersion: v1 +kind: Secret +metadata: + name: {{.SecretName}} + namespace: {{.TestNamespace}} +data: + AWS_ACCESS_KEY_ID: {{.AwsAccessKeyID}} + AWS_SECRET_ACCESS_KEY: {{.AwsSecretAccessKey}} +` + + triggerAuthenticationTemplate = `apiVersion: keda.sh/v1alpha1 +kind: TriggerAuthentication +metadata: + name: keda-trigger-auth-aws-credentials + namespace: {{.TestNamespace}} +spec: + secretTargetRef: + - parameter: awsAccessKeyID # Required. + name: {{.SecretName}} # Required. + key: AWS_ACCESS_KEY_ID # Required. + - parameter: awsSecretAccessKey # Required. + name: {{.SecretName}} # Required. + key: AWS_SECRET_ACCESS_KEY # Required. +` + + deploymentTemplate = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{.DeploymentName}} + namespace: {{.TestNamespace}} + labels: + app: {{.DeploymentName}} +spec: + replicas: 0 + selector: + matchLabels: + app: {{.DeploymentName}} + template: + metadata: + labels: + app: {{.DeploymentName}} + spec: + containers: + - name: nginx + image: nginxinc/nginx-unprivileged + ports: + - containerPort: 80 +` + + scaledObjectTemplate = ` +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: {{.ScaledObjectName}} + namespace: {{.TestNamespace}} + labels: + app: {{.DeploymentName}} +spec: + scaleTargetRef: + name: {{.DeploymentName}} + maxReplicaCount: 2 + minReplicaCount: 0 + cooldownPeriod: 1 + triggers: + - type: aws-cloudwatch + authenticationRef: + name: keda-trigger-auth-aws-credentials + metadata: + awsRegion: {{.AwsRegion}} + namespace: {{.CloudWatchMetricNamespace}} + dimensionName: {{.CloudWatchMetricDimensionName}} + dimensionValue: {{.CloudWatchMetricDimensionValue}} + metricName: {{.CloudWatchMetricName}} + targetMetricValue: "1" + minMetricValue: "1" + metricCollectionTime: "120" + metricStatPeriod: "60" +` +) + +var ( + testNamespace = fmt.Sprintf("%s-ns", testName) + deploymentName = fmt.Sprintf("%s-deployment", testName) + scaledObjectName = fmt.Sprintf("%s-so", testName) + secretName = fmt.Sprintf("%s-secret", testName) + cloudwatchMetricName = fmt.Sprintf("cw-%d", GetRandomNumber()) + awsAccessKeyID = os.Getenv("TF_AWS_ACCESS_KEY") + awsSecretAccessKey = os.Getenv("TF_AWS_SECRET_KEY") + awsRegion = os.Getenv("TF_AWS_REGION") + cloudwatchMetricNamespace = "DoesNotExist" + cloudwatchMetricDimensionName = "dimensionName" + cloudwatchMetricDimensionValue = "dimensionValue" + minReplicaCount = 0 + minMetricValueReplicaCount = 1 +) + +// This test is to verify that the scaler returns the minMetricValue when the metric +// value is null and ignoreNullValues is set to true. +func TestCloudWatchScalerWithMinMetricValue(t *testing.T) { + ctx := context.Background() + + // setup cloudwatch + cloudwatchClient, err := cloudwatch.NewClient(ctx, awsRegion, awsAccessKeyID, awsSecretAccessKey, "") + assert.Nil(t, err, "error creating cloudwatch client") + + // check that the metric in question is not already present, and is returning + // an empty set of values. + metricQuery := cloudwatch.CreateMetricDataInputForEmptyMetricValues(cloudwatchMetricNamespace, cloudwatchMetricName, cloudwatchMetricDimensionName, cloudwatchMetricDimensionValue) + metricData, err := cloudwatch.GetMetricData(ctx, cloudwatchClient, metricQuery) + require.Nil(t, err, "error getting metric data") + require.Nil(t, cloudwatch.ExpectEmptyMetricDataResults(metricData), "metric data should be empty") + + // Create kubernetes resources + kc := GetKubernetesClient(t) + data, templates := getTemplateData() + CreateKubernetesResources(t, kc, testNamespace, data, templates) + defer DeleteKubernetesResources(t, testNamespace, data, templates) + + assert.True(t, WaitForDeploymentReplicaReadyCount(t, kc, deploymentName, testNamespace, minReplicaCount, 60, 1), + "replica count should be %d after 1 minute", minMetricValueReplicaCount) + + // Allow a small amount of grace for stabilization, otherwise we will see the + // minMetricValue of 1 scale up the deployment from 0 to 1, as the deployment + // starts at a minReplicaCount of 0. The reason for this is to ensure that the + // scaler is still functioning when the metric value is null, as opposed to + // returning an error, and not scaling the workload at all. + time.Sleep(5 * time.Second) + + // Then check that the deployment did not scale further, as the metric query + // is returning null values, the scaler should evaluate the metric value as + // the minMetricValue of 1, and not scale the deployment further beyond this + // point. + AssertReplicaCountNotChangeDuringTimePeriod(t, kc, deploymentName, testNamespace, minMetricValueReplicaCount, 60) +} + +func getTemplateData() (templateData, []Template) { + return templateData{ + TestNamespace: testNamespace, + DeploymentName: deploymentName, + ScaledObjectName: scaledObjectName, + SecretName: secretName, + AwsAccessKeyID: base64.StdEncoding.EncodeToString([]byte(awsAccessKeyID)), + AwsSecretAccessKey: base64.StdEncoding.EncodeToString([]byte(awsSecretAccessKey)), + AwsRegion: awsRegion, + CloudWatchMetricName: cloudwatchMetricName, + CloudWatchMetricNamespace: cloudwatchMetricNamespace, + CloudWatchMetricDimensionName: cloudwatchMetricDimensionName, + CloudWatchMetricDimensionValue: cloudwatchMetricDimensionValue, + }, []Template{ + {Name: "secretTemplate", Config: secretTemplate}, + {Name: "triggerAuthenticationTemplate", Config: triggerAuthenticationTemplate}, + {Name: "deploymentTemplate", Config: deploymentTemplate}, + {Name: "scaledObjectTemplate", Config: scaledObjectTemplate}, + } +} diff --git a/tests/scalers/aws/helpers/cloudwatch/cloudwatch.go b/tests/scalers/aws/helpers/cloudwatch/cloudwatch.go new file mode 100644 index 00000000000..d30434e47f1 --- /dev/null +++ b/tests/scalers/aws/helpers/cloudwatch/cloudwatch.go @@ -0,0 +1,70 @@ +//go:build e2e +// +build e2e + +package cloudwatch + +import ( + "context" + "fmt" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/cloudwatch" + "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" +) + +// NewClient will provision a new Cloudwatch Client. +func NewClient(ctx context.Context, region, accessKeyID, secretAccessKey, sessionToken string) (*cloudwatch.Client, error) { + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + return nil, err + } + + cfg.Credentials = credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, sessionToken) + return cloudwatch.NewFromConfig(cfg), nil +} + +// CreateMetricDataInputForEmptyMetricValues will return a GetMetricDataInput with a single metric query +// that is expected to return no metric values. +func CreateMetricDataInputForEmptyMetricValues(metricNamespace, metricName, dimensionName, dimensionValue string) *cloudwatch.GetMetricDataInput { + return &cloudwatch.GetMetricDataInput{ + MetricDataQueries: []types.MetricDataQuery{ + { + Id: aws.String("m1"), + MetricStat: &types.MetricStat{ + Metric: &types.Metric{ + Namespace: &metricNamespace, + MetricName: &metricName, + Dimensions: []types.Dimension{ + { + Name: &dimensionName, + Value: &dimensionValue, + }, + }, + }, Period: aws.Int32(60), Stat: aws.String("Average"), + }, + }, + }, + // evaluate +/- 10 minutes from now to be sure we cover the query window + // as all e2e tests use a 300 second query window. + EndTime: aws.Time(time.Now().Add(time.Minute * 10)), + StartTime: aws.Time(time.Now().Add(-time.Minute * 10)), + } +} + +// GetMetricData will return the metric data for the given input. +func GetMetricData(ctx context.Context, cloudwatchClient *cloudwatch.Client, metricDataInput *cloudwatch.GetMetricDataInput) (*cloudwatch.GetMetricDataOutput, error) { + return cloudwatchClient.GetMetricData(ctx, metricDataInput) +} + +// ExpectEmptyMetricDataResults will evaluate the custom metric for any metric values, if any +// values are an error will be returned. +func ExpectEmptyMetricDataResults(metricData *cloudwatch.GetMetricDataOutput) error { + if len(metricData.MetricDataResults) != 1 || len(metricData.MetricDataResults[0].Values) > 0 { + return fmt.Errorf("found unexpected metric data results for metricData: %+v", metricData.MetricDataResults) + } + + return nil +}