diff --git a/pkg/tsdb/cloudwatch/cloudwatch_test.go b/pkg/tsdb/cloudwatch/cloudwatch_test.go index 7276740c504..d4712711860 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_test.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_test.go @@ -480,6 +480,42 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) { return datasourceInfo{}, nil }) + t.Run("Should handle dimension value request and return values from the api", func(t *testing.T) { + pageLimit := 100 + api = mocks.FakeMetricsAPI{Metrics: []*cloudwatch.Metric{ + {MetricName: aws.String("Test_MetricName1"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1"), Value: aws.String("Value1")}, {Name: aws.String("Test_DimensionName2"), Value: aws.String("Value2")}}}, + {MetricName: aws.String("Test_MetricName2"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1"), Value: aws.String("Value3")}}}, + {MetricName: aws.String("Test_MetricName3"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName2"), Value: aws.String("Value1")}}}, + {MetricName: aws.String("Test_MetricName10"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName4"), Value: aws.String("Value2")}, {Name: aws.String("Test_DimensionName5")}}}, + {MetricName: aws.String("Test_MetricName4"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName2"), Value: aws.String("Value3")}}}, + {MetricName: aws.String("Test_MetricName5"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1"), Value: aws.String("Value4")}}}, + {MetricName: aws.String("Test_MetricName6"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1"), Value: aws.String("Value6")}}}, + {MetricName: aws.String("Test_MetricName7"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName4"), Value: aws.String("Value7")}}}, + {MetricName: aws.String("Test_MetricName8"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName4"), Value: aws.String("Value1")}}}, + {MetricName: aws.String("Test_MetricName9"), Dimensions: []*cloudwatch.Dimension{{Name: aws.String("Test_DimensionName1"), Value: aws.String("Value2")}}}, + }, MetricsPerPage: 100} + executor := newExecutor(im, &setting.Cfg{AWSListMetricsPageLimit: pageLimit}, &fakeSessionCache{}, featuremgmt.WithFeatures()) + + req := &backend.CallResourceRequest{ + Method: "GET", + Path: `/dimension-values?region=us-east-2&dimensionKey=Test_DimensionName4&namespace=AWS/EC2&metricName=CPUUtilization`, + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ID: 0}, + PluginID: "cloudwatch", + }, + } + err := executor.CallResource(context.Background(), req, sender) + + require.NoError(t, err) + sent := sender.Response + require.NotNil(t, sent) + require.Equal(t, http.StatusOK, sent.Status) + res := []string{} + err = json.Unmarshal(sent.Body, &res) + require.Nil(t, err) + assert.Equal(t, []string{"Value1", "Value2", "Value7"}, res) + }) + t.Run("Should handle dimension key filter query and return keys from the api", func(t *testing.T) { pageLimit := 3 api = mocks.FakeMetricsAPI{Metrics: []*cloudwatch.Metric{ diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index ff686ad2c04..eccc3b7650d 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -166,79 +166,6 @@ func (e *cloudWatchExecutor) handleGetAllMetrics(pluginCtx backend.PluginContext return result, nil } -// handleGetDimensionValues returns a slice of suggestData structs with dimension values. -// A call to the list metrics api is issued to retrieve the dimension values. All parameters are used as input args to the list metrics call. -func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { - region := parameters.Get("region") - namespace := parameters.Get("namespace") - metricName := parameters.Get("metricName") - dimensionKey := parameters.Get("dimensionKey") - dimensionsJson := parameters.Get("dimensions") - - dimensionsValues := map[string]interface{}{} - err := json.Unmarshal([]byte(dimensionsJson), &dimensionsValues) - if err != nil { - return nil, fmt.Errorf("error unmarshaling dimension: %v", err) - } - - var dimensions []*cloudwatch.DimensionFilter - addDimension := func(key string, value string) { - filter := &cloudwatch.DimensionFilter{ - Name: aws.String(key), - } - // if value is not specified or a wildcard is used, simply don't use the value field - if value != "" && value != "*" { - filter.Value = aws.String(value) - } - dimensions = append(dimensions, filter) - } - - for k, v := range dimensionsValues { - if vv, ok := v.(string); ok { - addDimension(k, vv) - } else if vv, ok := v.([]interface{}); ok { - for _, v := range vv { - addDimension(k, v.(string)) - } - } else if v == nil { - addDimension(k, "") - } - } - - params := &cloudwatch.ListMetricsInput{ - Namespace: aws.String(namespace), - Dimensions: dimensions, - } - if metricName != "" { - params.MetricName = aws.String(metricName) - } - metrics, err := e.listMetrics(pluginCtx, region, params) - if err != nil { - return nil, err - } - - result := make([]suggestData, 0) - dupCheck := make(map[string]bool) - for _, metric := range metrics { - for _, dim := range metric.Dimensions { - if *dim.Name == dimensionKey { - if _, exists := dupCheck[*dim.Value]; exists { - continue - } - - dupCheck[*dim.Value] = true - result = append(result, suggestData{Text: *dim.Value, Value: *dim.Value, Label: *dim.Value}) - } - } - } - - sort.Slice(result, func(i, j int) bool { - return result[i].Text < result[j].Text - }) - - return result, nil -} - func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) { region := parameters.Get("region") instanceId := parameters.Get("instanceId") diff --git a/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go b/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go index cf18ce6db01..0be8f18e884 100644 --- a/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go +++ b/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go @@ -1,7 +1,7 @@ package mocks import ( - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" "github.com/stretchr/testify/mock" ) @@ -9,7 +9,13 @@ type ListMetricsServiceMock struct { mock.Mock } -func (a *ListMetricsServiceMock) GetDimensionKeysByDimensionFilter(*models.DimensionKeysRequest) ([]string, error) { +func (a *ListMetricsServiceMock) GetDimensionKeysByDimensionFilter(*request.DimensionKeysRequest) ([]string, error) { + args := a.Called() + + return args.Get(0).([]string), args.Error(1) +} + +func (a *ListMetricsServiceMock) GetDimensionValuesByDimensionFilter(r *request.DimensionValuesRequest) ([]string, error) { args := a.Called() return args.Get(0).([]string), args.Error(1) diff --git a/pkg/tsdb/cloudwatch/models/dimension_keys_request.go b/pkg/tsdb/cloudwatch/models/dimension_keys_request.go deleted file mode 100644 index 3f4342f71d1..00000000000 --- a/pkg/tsdb/cloudwatch/models/dimension_keys_request.go +++ /dev/null @@ -1,88 +0,0 @@ -package models - -import ( - "encoding/json" - "fmt" - "net/url" - - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/constants" -) - -type DimensionKeysRequestType uint32 - -const ( - StandardDimensionKeysRequest DimensionKeysRequestType = iota - FilterDimensionKeysRequest - CustomMetricDimensionKeysRequest -) - -type Dimension struct { - Name string - Value string -} - -type DimensionKeysRequest struct { - Region string `json:"region"` - Namespace string `json:"namespace"` - MetricName string `json:"metricName"` - DimensionFilter []*Dimension -} - -func (q *DimensionKeysRequest) Type() DimensionKeysRequestType { - if _, exist := constants.NamespaceMetricsMap[q.Namespace]; !exist { - return CustomMetricDimensionKeysRequest - } - - if len(q.DimensionFilter) > 0 { - return FilterDimensionKeysRequest - } - - return StandardDimensionKeysRequest -} - -func GetDimensionKeysRequest(parameters url.Values) (*DimensionKeysRequest, error) { - req := &DimensionKeysRequest{ - Region: parameters.Get("region"), - Namespace: parameters.Get("namespace"), - MetricName: parameters.Get("metricName"), - DimensionFilter: []*Dimension{}, - } - - if req.Region == "" { - return nil, fmt.Errorf("region is required") - } - - dimensionFilters := map[string]interface{}{} - dimensionFilterJson := []byte(parameters.Get("dimensionFilters")) - if len(dimensionFilterJson) > 0 { - err := json.Unmarshal(dimensionFilterJson, &dimensionFilters) - if err != nil { - return nil, fmt.Errorf("error unmarshaling dimensionFilters: %v", err) - } - } - addDimension := func(key string, value string) { - d := &Dimension{ - Name: key, - } - // if value is not specified or a wildcard is used, simply don't use the value field - if value != "" && value != "*" { - d.Value = value - } - req.DimensionFilter = append(req.DimensionFilter, d) - } - - for k, v := range dimensionFilters { - // due to legacy, value can be a string, a string slice or nil - if vv, ok := v.(string); ok { - addDimension(k, vv) - } else if vv, ok := v.([]interface{}); ok { - for _, v := range vv { - addDimension(k, v.(string)) - } - } else if v == nil { - addDimension(k, "") - } - } - - return req, nil -} diff --git a/pkg/tsdb/cloudwatch/models/dimension_keys_request_test.go b/pkg/tsdb/cloudwatch/models/dimension_keys_request_test.go deleted file mode 100644 index 0c35a1e7337..00000000000 --- a/pkg/tsdb/cloudwatch/models/dimension_keys_request_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package models - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDimensionKeyQuery(t *testing.T) { - t.Run("Should parse parameters without dimension filter", func(t *testing.T) { - req, err := GetDimensionKeysRequest(map[string][]string{ - "region": {"us-east-1"}, - "namespace": {"AWS/EC2"}, - "metricName": {"CPUUtilization"}}, - ) - require.NoError(t, err) - assert.Equal(t, "us-east-1", req.Region) - assert.Equal(t, "AWS/EC2", req.Namespace) - assert.Equal(t, "CPUUtilization", req.MetricName) - }) - - t.Run("Should parse parameters with single valued dimension filter", func(t *testing.T) { - req, err := GetDimensionKeysRequest(map[string][]string{ - "region": {"us-east-1"}, - "namespace": {"AWS/EC2"}, - "metricName": {"CPUUtilization"}, - "dimensionFilters": {"{\"InstanceId\": \"i-1234567890abcdef0\"}"}, - }) - require.NoError(t, err) - assert.Equal(t, "us-east-1", req.Region) - assert.Equal(t, "AWS/EC2", req.Namespace) - assert.Equal(t, "CPUUtilization", req.MetricName) - assert.Equal(t, 1, len(req.DimensionFilter)) - assert.Equal(t, "InstanceId", req.DimensionFilter[0].Name) - assert.Equal(t, "i-1234567890abcdef0", req.DimensionFilter[0].Value) - }) - - t.Run("Should parse parameters with multi-valued dimension filter", func(t *testing.T) { - req, err := GetDimensionKeysRequest(map[string][]string{ - "region": {"us-east-1"}, - "namespace": {"AWS/EC2"}, - "metricName": {"CPUUtilization"}, - "dimensionFilters": {"{\"InstanceId\": [\"i-1234567890abcdef0\", \"i-1234567890abcdef1\"]}"}, - }) - require.NoError(t, err) - assert.Equal(t, "us-east-1", req.Region) - assert.Equal(t, "AWS/EC2", req.Namespace) - assert.Equal(t, "CPUUtilization", req.MetricName) - assert.Equal(t, 2, len(req.DimensionFilter)) - assert.Equal(t, "InstanceId", req.DimensionFilter[0].Name) - assert.Equal(t, "i-1234567890abcdef0", req.DimensionFilter[0].Value) - assert.Equal(t, "InstanceId", req.DimensionFilter[1].Name) - assert.Equal(t, "i-1234567890abcdef1", req.DimensionFilter[1].Value) - }) - - t.Run("Should parse parameters with wildcard dimension filter", func(t *testing.T) { - req, err := GetDimensionKeysRequest(map[string][]string{ - "region": {"us-east-1"}, - "namespace": {"AWS/EC2"}, - "metricName": {"CPUUtilization"}, - "dimensionFilters": {"{\"InstanceId\": [\"*\"]}"}, - }) - require.NoError(t, err) - assert.Equal(t, "us-east-1", req.Region) - assert.Equal(t, "AWS/EC2", req.Namespace) - assert.Equal(t, "CPUUtilization", req.MetricName) - assert.Equal(t, 1, len(req.DimensionFilter)) - assert.Equal(t, "InstanceId", req.DimensionFilter[0].Name) - assert.Equal(t, "", req.DimensionFilter[0].Value) - }) -} diff --git a/pkg/tsdb/cloudwatch/models/metric_types.go b/pkg/tsdb/cloudwatch/models/metric_types.go index 40af418e09e..4eb63536b1e 100644 --- a/pkg/tsdb/cloudwatch/models/metric_types.go +++ b/pkg/tsdb/cloudwatch/models/metric_types.go @@ -2,12 +2,14 @@ package models import ( "github.com/aws/aws-sdk-go/service/cloudwatch" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" ) type ListMetricsProvider interface { - GetDimensionKeysByDimensionFilter(*DimensionKeysRequest) ([]string, error) + GetDimensionKeysByDimensionFilter(*request.DimensionKeysRequest) ([]string, error) GetHardCodedDimensionKeysByNamespace(string) ([]string, error) GetDimensionKeysByNamespace(string) ([]string, error) + GetDimensionValuesByDimensionFilter(*request.DimensionValuesRequest) ([]string, error) } type MetricsClientProvider interface { diff --git a/pkg/tsdb/cloudwatch/models/request/dimension_keys_request.go b/pkg/tsdb/cloudwatch/models/request/dimension_keys_request.go new file mode 100644 index 00000000000..0416e767ae9 --- /dev/null +++ b/pkg/tsdb/cloudwatch/models/request/dimension_keys_request.go @@ -0,0 +1,57 @@ +package request + +import ( + "net/url" + + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/constants" +) + +type DimensionKeysRequestType uint32 + +const ( + StandardDimensionKeysRequest DimensionKeysRequestType = iota + FilterDimensionKeysRequest + CustomMetricDimensionKeysRequest +) + +type DimensionKeysRequest struct { + *ResourceRequest + Namespace string + MetricName string + DimensionFilter []*Dimension +} + +func (q *DimensionKeysRequest) Type() DimensionKeysRequestType { + if _, exist := constants.NamespaceMetricsMap[q.Namespace]; !exist { + return CustomMetricDimensionKeysRequest + } + + if len(q.DimensionFilter) > 0 { + return FilterDimensionKeysRequest + } + + return StandardDimensionKeysRequest +} + +func GetDimensionKeysRequest(parameters url.Values) (*DimensionKeysRequest, error) { + resourceRequest, err := getResourceRequest(parameters) + if err != nil { + return nil, err + } + + request := &DimensionKeysRequest{ + ResourceRequest: resourceRequest, + Namespace: parameters.Get("namespace"), + MetricName: parameters.Get("metricName"), + DimensionFilter: []*Dimension{}, + } + + dimensions, err := parseDimensionFilter(parameters.Get("dimensionFilters")) + if err != nil { + return nil, err + } + + request.DimensionFilter = dimensions + + return request, nil +} diff --git a/pkg/tsdb/cloudwatch/models/request/dimension_keys_request_test.go b/pkg/tsdb/cloudwatch/models/request/dimension_keys_request_test.go new file mode 100644 index 00000000000..2285aefbc4e --- /dev/null +++ b/pkg/tsdb/cloudwatch/models/request/dimension_keys_request_test.go @@ -0,0 +1,72 @@ +package request + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDimensionKeyRequest(t *testing.T) { + t.Run("Should parse parameters without dimension filter", func(t *testing.T) { + request, err := GetDimensionKeysRequest(map[string][]string{ + "region": {"us-east-1"}, + "namespace": {"AWS/EC2"}, + "metricName": {"CPUUtilization"}}, + ) + require.NoError(t, err) + assert.Equal(t, "us-east-1", request.Region) + assert.Equal(t, "AWS/EC2", request.Namespace) + assert.Equal(t, "CPUUtilization", request.MetricName) + }) + + t.Run("Should parse parameters with single valued dimension filter", func(t *testing.T) { + request, err := GetDimensionKeysRequest(map[string][]string{ + "region": {"us-east-1"}, + "namespace": {"AWS/EC2"}, + "metricName": {"CPUUtilization"}, + "dimensionFilters": {"{\"InstanceId\": \"i-1234567890abcdef0\"}"}, + }) + require.NoError(t, err) + assert.Equal(t, "us-east-1", request.Region) + assert.Equal(t, "AWS/EC2", request.Namespace) + assert.Equal(t, "CPUUtilization", request.MetricName) + assert.Equal(t, 1, len(request.DimensionFilter)) + assert.Equal(t, "InstanceId", request.DimensionFilter[0].Name) + assert.Equal(t, "i-1234567890abcdef0", request.DimensionFilter[0].Value) + }) + + t.Run("Should parse parameters with multi-valued dimension filter", func(t *testing.T) { + request, err := GetDimensionKeysRequest(map[string][]string{ + "region": {"us-east-1"}, + "namespace": {"AWS/EC2"}, + "metricName": {"CPUUtilization"}, + "dimensionFilters": {"{\"InstanceId\": [\"i-1234567890abcdef0\", \"i-1234567890abcdef1\"]}"}, + }) + require.NoError(t, err) + assert.Equal(t, "us-east-1", request.Region) + assert.Equal(t, "AWS/EC2", request.Namespace) + assert.Equal(t, "CPUUtilization", request.MetricName) + assert.Equal(t, 2, len(request.DimensionFilter)) + assert.Equal(t, "InstanceId", request.DimensionFilter[0].Name) + assert.Equal(t, "i-1234567890abcdef0", request.DimensionFilter[0].Value) + assert.Equal(t, "InstanceId", request.DimensionFilter[1].Name) + assert.Equal(t, "i-1234567890abcdef1", request.DimensionFilter[1].Value) + }) + + t.Run("Should parse parameters with wildcard dimension filter", func(t *testing.T) { + request, err := GetDimensionKeysRequest(map[string][]string{ + "region": {"us-east-1"}, + "namespace": {"AWS/EC2"}, + "metricName": {"CPUUtilization"}, + "dimensionFilters": {"{\"InstanceId\": [\"*\"]}"}, + }) + require.NoError(t, err) + assert.Equal(t, "us-east-1", request.Region) + assert.Equal(t, "AWS/EC2", request.Namespace) + assert.Equal(t, "CPUUtilization", request.MetricName) + assert.Equal(t, 1, len(request.DimensionFilter)) + assert.Equal(t, "InstanceId", request.DimensionFilter[0].Name) + assert.Equal(t, "", request.DimensionFilter[0].Value) + }) +} diff --git a/pkg/tsdb/cloudwatch/models/request/dimension_values_request.go b/pkg/tsdb/cloudwatch/models/request/dimension_values_request.go new file mode 100644 index 00000000000..d14a8f4236a --- /dev/null +++ b/pkg/tsdb/cloudwatch/models/request/dimension_values_request.go @@ -0,0 +1,37 @@ +package request + +import ( + "net/url" +) + +type DimensionValuesRequest struct { + *ResourceRequest + Namespace string + MetricName string + DimensionKey string + DimensionFilter []*Dimension +} + +func GetDimensionValuesRequest(parameters url.Values) (*DimensionValuesRequest, error) { + resourceRequest, err := getResourceRequest(parameters) + if err != nil { + return nil, err + } + + request := &DimensionValuesRequest{ + ResourceRequest: resourceRequest, + Namespace: parameters.Get("namespace"), + MetricName: parameters.Get("metricName"), + DimensionKey: parameters.Get("dimensionKey"), + DimensionFilter: []*Dimension{}, + } + + dimensions, err := parseDimensionFilter(parameters.Get("dimensionFilters")) + if err != nil { + return nil, err + } + + request.DimensionFilter = dimensions + + return request, nil +} diff --git a/pkg/tsdb/cloudwatch/models/request/dimension_values_rquest_test.go b/pkg/tsdb/cloudwatch/models/request/dimension_values_rquest_test.go new file mode 100644 index 00000000000..4a381555c04 --- /dev/null +++ b/pkg/tsdb/cloudwatch/models/request/dimension_values_rquest_test.go @@ -0,0 +1,78 @@ +package request + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDimensionValuesRequest(t *testing.T) { + t.Run("Should parse parameters without dimension filter", func(t *testing.T) { + request, err := GetDimensionValuesRequest(map[string][]string{ + "region": {"us-east-1"}, + "namespace": {"AWS/EC2"}, + "metricName": {"CPUUtilization"}, + "dimensionKey": {"InstanceId"}}, + ) + require.NoError(t, err) + assert.Equal(t, "us-east-1", request.Region) + assert.Equal(t, "AWS/EC2", request.Namespace) + assert.Equal(t, "CPUUtilization", request.MetricName) + assert.Equal(t, "InstanceId", request.DimensionKey) + }) + + t.Run("Should parse parameters with single valued dimension filter", func(t *testing.T) { + request, err := GetDimensionValuesRequest(map[string][]string{ + "region": {"us-east-1"}, + "namespace": {"AWS/EC2"}, + "metricName": {"CPUUtilization"}, + "dimensionKey": {"InstanceId"}, + "dimensionFilters": {"{\"InstanceId\": \"i-1234567890abcdef0\"}"}, + }) + require.NoError(t, err) + assert.Equal(t, "us-east-1", request.Region) + assert.Equal(t, "AWS/EC2", request.Namespace) + assert.Equal(t, "CPUUtilization", request.MetricName) + assert.Equal(t, 1, len(request.DimensionFilter)) + assert.Equal(t, "InstanceId", request.DimensionKey) + assert.Equal(t, "InstanceId", request.DimensionFilter[0].Name) + assert.Equal(t, "i-1234567890abcdef0", request.DimensionFilter[0].Value) + }) + + t.Run("Should parse parameters with multi-valued dimension filter", func(t *testing.T) { + request, err := GetDimensionValuesRequest(map[string][]string{ + "region": {"us-east-1"}, + "namespace": {"AWS/EC2"}, + "metricName": {"CPUUtilization"}, + "dimensionKey": {"InstanceId"}, + "dimensionFilters": {"{\"InstanceId\": [\"i-1234567890abcdef0\", \"i-1234567890abcdef1\"]}"}, + }) + require.NoError(t, err) + assert.Equal(t, "us-east-1", request.Region) + assert.Equal(t, "AWS/EC2", request.Namespace) + assert.Equal(t, "CPUUtilization", request.MetricName) + assert.Equal(t, 2, len(request.DimensionFilter)) + assert.Equal(t, "InstanceId", request.DimensionKey) + assert.Equal(t, "InstanceId", request.DimensionFilter[0].Name) + assert.Equal(t, "i-1234567890abcdef0", request.DimensionFilter[0].Value) + assert.Equal(t, "InstanceId", request.DimensionFilter[1].Name) + assert.Equal(t, "i-1234567890abcdef1", request.DimensionFilter[1].Value) + }) + + t.Run("Should parse parameters with wildcard dimension filter", func(t *testing.T) { + request, err := GetDimensionValuesRequest(map[string][]string{ + "region": {"us-east-1"}, + "namespace": {"AWS/EC2"}, + "metricName": {"CPUUtilization"}, + "dimensionFilters": {"{\"InstanceId\": [\"*\"]}"}, + }) + require.NoError(t, err) + assert.Equal(t, "us-east-1", request.Region) + assert.Equal(t, "AWS/EC2", request.Namespace) + assert.Equal(t, "CPUUtilization", request.MetricName) + assert.Equal(t, 1, len(request.DimensionFilter)) + assert.Equal(t, "InstanceId", request.DimensionFilter[0].Name) + assert.Equal(t, "", request.DimensionFilter[0].Value) + }) +} diff --git a/pkg/tsdb/cloudwatch/models/request/resource_request.go b/pkg/tsdb/cloudwatch/models/request/resource_request.go new file mode 100644 index 00000000000..5f541b0feb1 --- /dev/null +++ b/pkg/tsdb/cloudwatch/models/request/resource_request.go @@ -0,0 +1,22 @@ +package request + +import ( + "fmt" + "net/url" +) + +type ResourceRequest struct { + Region string +} + +func getResourceRequest(parameters url.Values) (*ResourceRequest, error) { + request := &ResourceRequest{ + Region: parameters.Get("region"), + } + + if request.Region == "" { + return nil, fmt.Errorf("region is required") + } + + return request, nil +} diff --git a/pkg/tsdb/cloudwatch/models/request/resource_request_test.go b/pkg/tsdb/cloudwatch/models/request/resource_request_test.go new file mode 100644 index 00000000000..4f9f38f611e --- /dev/null +++ b/pkg/tsdb/cloudwatch/models/request/resource_request_test.go @@ -0,0 +1,16 @@ +package request + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResourceRequest(t *testing.T) { + t.Run("Should return an error if region is not provided", func(t *testing.T) { + request, err := GetDimensionValuesRequest(map[string][]string{}) + require.Nil(t, request) + assert.Equal(t, "region is required", err.Error()) + }) +} diff --git a/pkg/tsdb/cloudwatch/models/request/types.go b/pkg/tsdb/cloudwatch/models/request/types.go new file mode 100644 index 00000000000..e06440bc9c8 --- /dev/null +++ b/pkg/tsdb/cloudwatch/models/request/types.go @@ -0,0 +1,6 @@ +package request + +type Dimension struct { + Name string + Value string +} diff --git a/pkg/tsdb/cloudwatch/models/request/utils.go b/pkg/tsdb/cloudwatch/models/request/utils.go new file mode 100644 index 00000000000..6e12506d49f --- /dev/null +++ b/pkg/tsdb/cloudwatch/models/request/utils.go @@ -0,0 +1,44 @@ +package request + +import ( + "encoding/json" + "fmt" +) + +func parseDimensionFilter(dimensionFilter string) ([]*Dimension, error) { + dimensionFilters := map[string]interface{}{} + dimensionFilterJson := []byte(dimensionFilter) + if len(dimensionFilterJson) > 0 { + err := json.Unmarshal(dimensionFilterJson, &dimensionFilters) + if err != nil { + return nil, fmt.Errorf("error unmarshaling dimensionFilters: %v", err) + } + } + + dimensions := []*Dimension{} + addDimension := func(key string, value string) { + d := &Dimension{ + Name: key, + } + // if value is not specified or a wildcard is used, simply don't use the value field + if value != "" && value != "*" { + d.Value = value + } + dimensions = append(dimensions, d) + } + + for k, v := range dimensionFilters { + // due to legacy, value can be a string, a string slice or nil + if vv, ok := v.(string); ok { + addDimension(k, vv) + } else if vv, ok := v.([]interface{}); ok { + for _, v := range vv { + addDimension(k, v.(string)) + } + } else if v == nil { + addDimension(k, "") + } + } + + return dimensions, nil +} diff --git a/pkg/tsdb/cloudwatch/resource_handler.go b/pkg/tsdb/cloudwatch/resource_handler.go index 0db3c5531b5..95b2aa66615 100644 --- a/pkg/tsdb/cloudwatch/resource_handler.go +++ b/pkg/tsdb/cloudwatch/resource_handler.go @@ -18,12 +18,12 @@ func (e *cloudWatchExecutor) newResourceMux() *http.ServeMux { mux.HandleFunc("/namespaces", handleResourceReq(e.handleGetNamespaces)) mux.HandleFunc("/metrics", handleResourceReq(e.handleGetMetrics)) mux.HandleFunc("/all-metrics", handleResourceReq(e.handleGetAllMetrics)) - mux.HandleFunc("/dimension-values", handleResourceReq(e.handleGetDimensionValues)) mux.HandleFunc("/ebs-volume-ids", handleResourceReq(e.handleGetEbsVolumeIds)) mux.HandleFunc("/ec2-instance-attribute", handleResourceReq(e.handleGetEc2InstanceAttribute)) mux.HandleFunc("/resource-arns", handleResourceReq(e.handleGetResourceArns)) mux.HandleFunc("/log-groups", handleResourceReq(e.handleGetLogGroups)) mux.HandleFunc("/all-log-groups", handleResourceReq(e.handleGetAllLogGroups)) + mux.HandleFunc("/dimension-values", routes.ResourceRequestMiddleware(routes.DimensionValuesHandler, e.getClients)) mux.HandleFunc("/dimension-keys", routes.ResourceRequestMiddleware(routes.DimensionKeysHandler, e.getClients)) return mux } diff --git a/pkg/tsdb/cloudwatch/routes/dimension_keys.go b/pkg/tsdb/cloudwatch/routes/dimension_keys.go index 8a2eaacbb66..743ac46b45e 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_keys.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_keys.go @@ -7,11 +7,12 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/services" ) func DimensionKeysHandler(pluginCtx backend.PluginContext, clientFactory models.ClientsFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) { - dimensionKeysRequest, err := models.GetDimensionKeysRequest(parameters) + dimensionKeysRequest, err := request.GetDimensionKeysRequest(parameters) if err != nil { return nil, models.NewHttpError("error in DimensionKeyHandler", http.StatusBadRequest, err) } @@ -23,11 +24,11 @@ func DimensionKeysHandler(pluginCtx backend.PluginContext, clientFactory models. dimensionKeys := []string{} switch dimensionKeysRequest.Type() { - case models.StandardDimensionKeysRequest: + case request.StandardDimensionKeysRequest: dimensionKeys, err = service.GetHardCodedDimensionKeysByNamespace(dimensionKeysRequest.Namespace) - case models.FilterDimensionKeysRequest: + case request.FilterDimensionKeysRequest: dimensionKeys, err = service.GetDimensionKeysByDimensionFilter(dimensionKeysRequest) - case models.CustomMetricDimensionKeysRequest: + case request.CustomMetricDimensionKeysRequest: dimensionKeys, err = service.GetDimensionKeysByNamespace(dimensionKeysRequest.Namespace) } if err != nil { diff --git a/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go b/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go index d47b19c940e..f68b9258798 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go @@ -13,22 +13,6 @@ import ( ) func Test_DimensionKeys_Route(t *testing.T) { - t.Run("rejects POST method", func(t *testing.T) { - rr := httptest.NewRecorder() - req := httptest.NewRequest("POST", "/dimension-keys?region=us-east-1", nil) - handler := http.HandlerFunc(ResourceRequestMiddleware(DimensionKeysHandler, nil)) - handler.ServeHTTP(rr, req) - assert.Equal(t, http.StatusMethodNotAllowed, rr.Code) - }) - - t.Run("requires region query value", func(t *testing.T) { - rr := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/dimension-keys", nil) - handler := http.HandlerFunc(ResourceRequestMiddleware(DimensionKeysHandler, nil)) - handler.ServeHTTP(rr, req) - assert.Equal(t, http.StatusBadRequest, rr.Code) - }) - tests := []struct { url string methodName string diff --git a/pkg/tsdb/cloudwatch/routes/dimension_values.go b/pkg/tsdb/cloudwatch/routes/dimension_values.go new file mode 100644 index 00000000000..330fa4c5f79 --- /dev/null +++ b/pkg/tsdb/cloudwatch/routes/dimension_values.go @@ -0,0 +1,35 @@ +package routes + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" +) + +func DimensionValuesHandler(pluginCtx backend.PluginContext, clientFactory models.ClientsFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) { + dimensionValuesRequest, err := request.GetDimensionValuesRequest(parameters) + if err != nil { + return nil, models.NewHttpError("error in DimensionValuesHandler", http.StatusBadRequest, err) + } + + service, err := newListMetricsService(pluginCtx, clientFactory, dimensionValuesRequest.Region) + if err != nil { + return nil, models.NewHttpError("error in DimensionValuesHandler", http.StatusInternalServerError, err) + } + + dimensionValues, err := service.GetDimensionValuesByDimensionFilter(dimensionValuesRequest) + if err != nil { + return nil, models.NewHttpError("error in DimensionValuesHandler", http.StatusInternalServerError, err) + } + + dimensionValuesResponse, err := json.Marshal(dimensionValues) + if err != nil { + return nil, models.NewHttpError("error in DimensionValuesHandler", http.StatusInternalServerError, err) + } + + return dimensionValuesResponse, nil +} diff --git a/pkg/tsdb/cloudwatch/routes/dimension_values_test.go b/pkg/tsdb/cloudwatch/routes/dimension_values_test.go new file mode 100644 index 00000000000..ee840f0154b --- /dev/null +++ b/pkg/tsdb/cloudwatch/routes/dimension_values_test.go @@ -0,0 +1,41 @@ +package routes + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/stretchr/testify/assert" +) + +func Test_DimensionValues_Route(t *testing.T) { + t.Run("Calls GetDimensionValuesByDimensionFilter when a valid request is passed", func(t *testing.T) { + mockListMetricsService := mocks.ListMetricsServiceMock{} + mockListMetricsService.On("GetDimensionValuesByDimensionFilter").Return([]string{}, nil) + newListMetricsService = func(pluginCtx backend.PluginContext, clientFactory models.ClientsFactoryFunc, region string) (models.ListMetricsProvider, error) { + return &mockListMetricsService, nil + } + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", `/dimension-values?region=us-east-2&dimensionKey=instanceId&namespace=AWS/EC2&metricName=CPUUtilization&dimensionFilters={"NodeID":["Shared"],"stage":["QueryCommit"]}`, nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(DimensionValuesHandler, nil)) + handler.ServeHTTP(rr, req) + }) + + t.Run("returns 500 if GetDimensionValuesByDimensionFilter returns an error", func(t *testing.T) { + mockListMetricsService := mocks.ListMetricsServiceMock{} + mockListMetricsService.On("GetDimensionValuesByDimensionFilter").Return([]string{}, fmt.Errorf("some error")) + newListMetricsService = func(pluginCtx backend.PluginContext, clientFactory models.ClientsFactoryFunc, region string) (models.ListMetricsProvider, error) { + return &mockListMetricsService, nil + } + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", `/dimension-values?region=us-east-2&dimensionKey=instanceId&namespace=AWS/EC2&metricName=CPUUtilization&dimensionFilters={"NodeID":["Shared"],"stage":["QueryCommit"]}`, nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(DimensionValuesHandler, nil)) + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.Equal(t, `{"Message":"error in DimensionValuesHandler: some error","Error":"some error","StatusCode":500}`, rr.Body.String()) + }) +} diff --git a/pkg/tsdb/cloudwatch/routes/middleware_test.go b/pkg/tsdb/cloudwatch/routes/middleware_test.go new file mode 100644 index 00000000000..15fd2facf0f --- /dev/null +++ b/pkg/tsdb/cloudwatch/routes/middleware_test.go @@ -0,0 +1,48 @@ +package routes + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/stretchr/testify/assert" +) + +func Test_Middleware(t *testing.T) { + t.Run("rejects POST method", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/dimension-keys?region=us-east-1", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(func(pluginCtx backend.PluginContext, clientFactory models.ClientsFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) { + return []byte{}, nil + }, nil)) + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusMethodNotAllowed, rr.Code) + }) + + t.Run("injects plugincontext to handler", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/some-path", nil) + var testPluginContext backend.PluginContext + handler := http.HandlerFunc(ResourceRequestMiddleware(func(pluginCtx backend.PluginContext, clientFactory models.ClientsFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) { + testPluginContext = pluginCtx + return []byte{}, nil + }, nil)) + handler.ServeHTTP(rr, req) + assert.NotNil(t, testPluginContext) + }) + + t.Run("should propagate handler error to response", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/some-path", nil) + handler := http.HandlerFunc(ResourceRequestMiddleware(func(pluginCtx backend.PluginContext, clientFactory models.ClientsFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) { + return []byte{}, models.NewHttpError("error", http.StatusBadRequest, fmt.Errorf("error from handler")) + }, nil)) + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Equal(t, `{"Message":"error: error from handler","Error":"error from handler","StatusCode":400}`, rr.Body.String()) + }) +} diff --git a/pkg/tsdb/cloudwatch/services/list_metrics.go b/pkg/tsdb/cloudwatch/services/list_metrics.go index 672683d4542..8c18588f1e9 100644 --- a/pkg/tsdb/cloudwatch/services/list_metrics.go +++ b/pkg/tsdb/cloudwatch/services/list_metrics.go @@ -2,11 +2,13 @@ package services import ( "fmt" + "sort" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/constants" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" ) type ListMetricsService struct { @@ -26,7 +28,7 @@ func (*ListMetricsService) GetHardCodedDimensionKeysByNamespace(namespace string return dimensionKeys, nil } -func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r *models.DimensionKeysRequest) ([]string, error) { +func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r *request.DimensionKeysRequest) ([]string, error) { input := &cloudwatch.ListMetricsInput{} if r.Namespace != "" { input.Namespace = aws.String(r.Namespace) @@ -34,15 +36,7 @@ func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r *models.Dimensi if r.MetricName != "" { input.MetricName = aws.String(r.MetricName) } - for _, dimension := range r.DimensionFilter { - df := &cloudwatch.DimensionFilter{ - Name: aws.String(dimension.Name), - } - if dimension.Value != "" { - df.Value = aws.String(dimension.Value) - } - input.Dimensions = append(input.Dimensions, df) - } + setDimensionFilter(input, r.DimensionFilter) metrics, err := l.ListMetricsWithPageLimit(input) if err != nil { @@ -79,6 +73,37 @@ func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r *models.Dimensi return dimensionKeys, nil } +func (l *ListMetricsService) GetDimensionValuesByDimensionFilter(r *request.DimensionValuesRequest) ([]string, error) { + input := &cloudwatch.ListMetricsInput{ + Namespace: aws.String(r.Namespace), + MetricName: aws.String(r.MetricName), + } + setDimensionFilter(input, r.DimensionFilter) + + metrics, err := l.ListMetricsWithPageLimit(input) + if err != nil { + return nil, fmt.Errorf("%v: %w", "unable to call AWS API", err) + } + + var dimensionValues []string + dupCheck := make(map[string]bool) + for _, metric := range metrics { + for _, dim := range metric.Dimensions { + if *dim.Name == r.DimensionKey { + if _, exists := dupCheck[*dim.Value]; exists { + continue + } + + dupCheck[*dim.Value] = true + dimensionValues = append(dimensionValues, *dim.Value) + } + } + } + + sort.Strings(dimensionValues) + return dimensionValues, nil +} + func (l *ListMetricsService) GetDimensionKeysByNamespace(namespace string) ([]string, error) { metrics, err := l.ListMetricsWithPageLimit(&cloudwatch.ListMetricsInput{Namespace: aws.String(namespace)}) if err != nil { @@ -100,3 +125,15 @@ func (l *ListMetricsService) GetDimensionKeysByNamespace(namespace string) ([]st return dimensionKeys, nil } + +func setDimensionFilter(input *cloudwatch.ListMetricsInput, dimensionFilter []*request.Dimension) { + for _, dimension := range dimensionFilter { + df := &cloudwatch.DimensionFilter{ + Name: aws.String(dimension.Name), + } + if dimension.Value != "" { + df.Value = aws.String(dimension.Value) + } + input.Dimensions = append(input.Dimensions, df) + } +} diff --git a/pkg/tsdb/cloudwatch/services/list_metrics_test.go b/pkg/tsdb/cloudwatch/services/list_metrics_test.go index 73a92788b82..b5b8057b1dc 100644 --- a/pkg/tsdb/cloudwatch/services/list_metrics_test.go +++ b/pkg/tsdb/cloudwatch/services/list_metrics_test.go @@ -6,7 +6,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks" - "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models" + "github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/request" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -64,11 +64,11 @@ func TestListMetricsService_GetDimensionKeysByDimensionFilter(t *testing.T) { fakeMetricsClient.On("ListMetricsWithPageLimit", mock.Anything).Return(metricResponse, nil) listMetricsService := NewListMetricsService(fakeMetricsClient) - resp, err := listMetricsService.GetDimensionKeysByDimensionFilter(&models.DimensionKeysRequest{ - Region: "us-east-1", - Namespace: "AWS/EC2", - MetricName: "CPUUtilization", - DimensionFilter: []*models.Dimension{ + resp, err := listMetricsService.GetDimensionKeysByDimensionFilter(&request.DimensionKeysRequest{ + ResourceRequest: &request.ResourceRequest{Region: "us-east-1"}, + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + DimensionFilter: []*request.Dimension{ {Name: "InstanceId", Value: ""}, }, }) @@ -90,3 +90,24 @@ func TestListMetricsService_GetDimensionKeysByNamespace(t *testing.T) { assert.Equal(t, []string{"InstanceId", "InstanceType", "AutoScalingGroupName"}, resp) }) } + +func TestListMetricsService_GetDimensionValuesByDimensionFilter(t *testing.T) { + t.Run("Should filter out duplicates and keys matching dimension filter keys", func(t *testing.T) { + fakeMetricsClient := &mocks.FakeMetricsClient{} + fakeMetricsClient.On("ListMetricsWithPageLimit", mock.Anything).Return(metricResponse, nil) + listMetricsService := NewListMetricsService(fakeMetricsClient) + + resp, err := listMetricsService.GetDimensionValuesByDimensionFilter(&request.DimensionValuesRequest{ + ResourceRequest: &request.ResourceRequest{Region: "us-east-1"}, + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + DimensionKey: "InstanceId", + DimensionFilter: []*request.Dimension{ + {Name: "InstanceId", Value: ""}, + }, + }) + + require.NoError(t, err) + assert.Equal(t, []string{"i-1234567890abcdef0", "i-5234567890abcdef0", "i-64234567890abcdef0"}, resp) + }) +} diff --git a/public/app/plugins/datasource/cloudwatch/api.ts b/public/app/plugins/datasource/cloudwatch/api.ts index bb2d6700eb1..d809ee53ef7 100644 --- a/public/app/plugins/datasource/cloudwatch/api.ts +++ b/public/app/plugins/datasource/cloudwatch/api.ts @@ -88,19 +88,19 @@ export class CloudWatchAPI extends CloudWatchRequest { namespace: string | undefined, metricName: string | undefined, dimensionKey: string, - filterDimensions: {} + dimensionFilters: {} ) { if (!namespace || !metricName) { return []; } - const values = await this.memoizedGetRequest('dimension-values', { + const values = await this.memoizedGetRequest('dimension-values', { region: this.templateSrv.replace(this.getActualRegion(region)), namespace: this.templateSrv.replace(namespace), metricName: this.templateSrv.replace(metricName.trim()), dimensionKey: this.templateSrv.replace(dimensionKey), - dimensions: JSON.stringify(this.convertDimensionFormat(filterDimensions, {})), - }); + dimensionFilters: JSON.stringify(this.convertDimensionFormat(dimensionFilters, {})), + }).then((dimensionValues) => dimensionValues.map(toOption)); return values; }