CloudWatch: Cross-account querying support (#59362)

* Lattice: Point to private prerelease of aws-sdk-go (#515)

* point to private prerelease of aws-sdk-go

* fix build issue

* Lattice: Adding a feature toggle (#549)

* Adding a feature toggle for lattice

* Change name of feature toggle

* Lattice: List accounts (#543)

* Separate layers

* Introduce testify/mock library

Co-authored-by: Shirley Leu <4163034+fridgepoet@users.noreply.github.com>

* point to version that includes metric api changes (#574)

* add accounts component (#575)

* Test refactor: remove unneeded clientFactoryMock (#581)

* Lattice: Add monitoring badge (#576)

* add monitoring badge

* fix tests

* solve conflict

* Lattice: Add dynamic label for account display name (#579)

* Build: Automatically sync lattice-main with OSS

* Lattice: Point to private prerelease of aws-sdk-go (#515)

* point to private prerelease of aws-sdk-go

* fix build issue

* Lattice: Adding a feature toggle (#549)

* Adding a feature toggle for lattice

* Change name of feature toggle

* Lattice: List accounts (#543)

* Separate layers

* Introduce testify/mock library

Co-authored-by: Shirley Leu <4163034+fridgepoet@users.noreply.github.com>

* point to version that includes metric api changes (#574)

* add accounts component (#575)

* Test refactor: remove unneeded clientFactoryMock (#581)

* Lattice: Add monitoring badge (#576)

* add monitoring badge

* fix tests

* solve conflict

* add account label

Co-authored-by: Shirley Leu <4163034+fridgepoet@users.noreply.github.com>
Co-authored-by: Sarah Zinger <sarah.zinger@grafana.com>

* fix import

* solve merge related problem

* add account info (#608)

* add back namespaces handler

* Lattice: Parse account id and return it to frontend (#609)

* parse account id and return to frontend

* fix route test

* only show badge when feature toggle is enabled (#615)

* Lattice: Refactor resource response type and return account (#613)

* refactor resource response type

* remove not used file.

* go lint

* fix tests

* remove commented code

* Lattice: Use account as input when listing metric names and dimensions (#611)

* use account in resource requests

* add account to response

* revert accountInfo to accountId

* PR feedback

* unit test account in list metrics response

* remove not used asserts

* don't assert on response that is not relevant to the test

* removed dupe test

* pr feedback

* rename request package (#626)

* Lattice: Move account component and add tooltip (#630)

* move accounts component to the top of metric stat editor

* add tooltip

* CloudWatch: add account to GetMetricData queries (#627)

* Add AccountId to metric stat query

* Lattice: Account variable support  (#625)

* add variable support in accounts component

* add account variable query type

* update variables

* interpolate variable before its sent to backend

* handle variable change in hooks

* remove not used import

* Update public/app/plugins/datasource/cloudwatch/components/Account.tsx

Co-authored-by: Sarah Zinger <sarah.zinger@grafana.com>

* Update public/app/plugins/datasource/cloudwatch/hooks.ts

Co-authored-by: Sarah Zinger <sarah.zinger@grafana.com>

* add one more unit test

Co-authored-by: Sarah Zinger <sarah.zinger@grafana.com>

* cleanup (#629)

* Set account Id according to crossAccountQuerying feature flag in backend (#632)

* CloudWatch: Change spelling of feature-toggle (#634)

* Lattice Logs (#631)

* Lattice Logs

* Fixes after CR

* Lattice: Bug: fix dimension keys request (#644)

* fix dimension keys

* fix lint

* more lint

* CloudWatch: Add tests for QueryData with AccountId (#637)

* Update from breaking change (#645)

* Update from breaking change

* Remove extra interface and methods

Co-authored-by: Shirley Leu <4163034+fridgepoet@users.noreply.github.com>

* CloudWatch: Add business logic layer for getting log groups (#642)



Co-authored-by: Sarah Zinger <sarah.zinger@grafana.com>

* Lattice: Fix - unset account id in region change handler (#646)

* move reset of account to region change handler

* fix broken test

* Lattice: Add account id to metric stat query deep link (#656)

add account id to metric stat link

* CloudWatch: Add new log groups handler for cross-account querying (#643)

* Lattice: Add feature tracking (#660)

* add tracking for account id prescense in metrics query

* also check feature toggle

* fix broken test

* CloudWatch: Add route for DescribeLogGroups for cross-account querying (#647)

Co-authored-by: Erik Sundell <erik.sundell87@gmail.com>

* Lattice: Handle account id default value (#662)

* make sure right type is returned

* set right default values

* Suggestions to lattice changes (#663)

* Change ListMetricsWithPageLimit response to slice of non-pointers

* Change GetAccountsForCurrentUserOrRole response to be not pointer

* Clean test Cleanup calls in test

* Remove CloudWatchAPI as part of mock

* Resolve conflicts

* Add Latest SDK (#672)

* add tooltip (#674)

* Docs: Add documentation for CloudWatch cross account querying (#676)

* wip docs

* change wordings

* add sections about metrics and logs

* change from monitoring to observability

* Update docs/sources/datasources/aws-cloudwatch/_index.md

Co-authored-by: Sarah Zinger <sarah.zinger@grafana.com>

* Update docs/sources/datasources/aws-cloudwatch/query-editor/index.md

Co-authored-by: Fiona Artiaga <89225282+GrafanaWriter@users.noreply.github.com>

* Update docs/sources/datasources/aws-cloudwatch/query-editor/index.md

Co-authored-by: Fiona Artiaga <89225282+GrafanaWriter@users.noreply.github.com>

* Update docs/sources/datasources/aws-cloudwatch/query-editor/index.md

Co-authored-by: Sarah Zinger <sarah.zinger@grafana.com>

* Update docs/sources/datasources/aws-cloudwatch/query-editor/index.md

Co-authored-by: Fiona Artiaga <89225282+GrafanaWriter@users.noreply.github.com>

* apply pr feedback

* fix file name

* more pr feedback

* pr feedback

Co-authored-by: Sarah Zinger <sarah.zinger@grafana.com>
Co-authored-by: Fiona Artiaga <89225282+GrafanaWriter@users.noreply.github.com>

* use latest version of the aws-sdk-go

* Fix tests' mock response type

* Remove change in Azure Monitor

Co-authored-by: Sarah Zinger <sarah.zinger@grafana.com>
Co-authored-by: Shirley Leu <4163034+fridgepoet@users.noreply.github.com>
Co-authored-by: Fiona Artiaga <89225282+GrafanaWriter@users.noreply.github.com>
This commit is contained in:
Erik Sundell
2022-11-28 12:39:12 +01:00
committed by GitHub
parent 5b861faec3
commit 254577ba56
100 changed files with 3945 additions and 475 deletions

View File

@@ -5373,8 +5373,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/datasource/cloudwatch/datasource.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]

View File

@@ -168,6 +168,21 @@ You can attach these permissions to the IAM role or IAM user you configured in [
}
```
**Cross-account observability:**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Action": ["oam:ListSinks", "oam:ListAttachedLinks"],
"Effect": "Allow",
"Resource": "*"
}
]
}
```
### Configure CloudWatch settings
#### Namespaces of Custom Metrics

View File

@@ -214,6 +214,33 @@ When making `stats` queries in [Explore]({{< relref "../../../explore/" >}}), ma
{{< figure src="/static/img/docs/v70/explore-mode-switcher.png" max-width="500px" class="docs-image--right" caption="Explore mode switcher" >}}
## Cross-account observability
The CloudWatch plugin provides the ability to monitor and troubleshoot applications that span across multiple accounts within a region. Using cross-account observability, you can seamlessly search, visualize and analyze metrics and logs, without having to worry about account boundaries.
> **Note:** This feature is currently behind the `cloudWatchCrossAccountQuerying` feature toggle.
> You can enable feature toggles through configuration file or environment variables. See configuration [docs]({{< relref "../setup-grafana/configure-grafana/#feature_toggles" >}}) for details.
> Grafana Cloud users can access this feature by [opening a support ticket in the Cloud Portal](https://grafana.com/profile/org#support).
### Getting started
To enable cross-account observability, first enable it in CloudWatch using the official [CloudWatch docs](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html), then add [two new API actions]({{< relref "../#cross-account-observability" >}}) to the IAM policy attached to the role/user running the plugin.
Cross-account querying is available in the plugin through the `Logs` mode and the `Metric search` mode. Once you have it configured correctly, you'll see a "Monitoring account" badge displayed in the query editor header.
{{< figure src="/static/img/docs/cloudwatch/cloudwatch-monitoring-badge-9.3.0.png" max-width="1200px" caption="Monitoring account badge" >}}
### Metrics editor
When you select the `Builder` mode within the Metric search editor, a new Account field displays. Use the Account field to specify which of the linked accounts to target for the given query. By default, the `All` option is specified, which will target all linked accounts.
While in `Code` mode, you can specify any math expression. If the Monitoring account badge displays in the query editor header, all `SEARCH` expressions entered in this field will be cross-account by default. You can limit the search to one or a set of accounts, as documented in the [AWS documentation](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html).
### Logs editor
The Log group selector allows you to specify what log groups to target in the logs query. If the Monitoring account badge is displayed in the query editor header, it is possible to search and select log groups across multiple accounts. You can use the Account field in the Log Group Selector to filter Log Groups by Account. If you have many log groups and do not see the log group you'd like to select in the selector, use the prefix search to narrow down the possible log groups.
### Deep-link Grafana panels to the CloudWatch console
{{< figure src="/static/img/docs/v70/cloudwatch-logs-deep-linking.png" max-width="500px" class="docs-image--right" caption="CloudWatch Logs deep linking" >}}

6
go.mod
View File

@@ -29,7 +29,7 @@ require (
github.com/BurntSushi/toml v1.1.0
github.com/Masterminds/semver v1.5.0
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f
github.com/aws/aws-sdk-go v1.44.109
github.com/aws/aws-sdk-go v1.44.146
github.com/beevik/etree v1.1.0
github.com/benbjohnson/clock v1.3.0
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b
@@ -108,7 +108,7 @@ require (
go.opentelemetry.io/otel/trace v1.7.0
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d
golang.org/x/net v0.0.0-20220909164309-bea034e7d591 // indirect
golang.org/x/net v0.1.0 // indirect
golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0
golang.org/x/sync v0.1.0
golang.org/x/time v0.0.0-20220609170525-579cf78fd858
@@ -230,7 +230,7 @@ require (
go.opencensus.io v0.23.0 // indirect
go.uber.org/atomic v1.9.0
go.uber.org/goleak v1.1.12 // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/text v0.4.0
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
google.golang.org/appengine v1.6.7 // indirect

13
go.sum
View File

@@ -373,8 +373,9 @@ github.com/aws/aws-sdk-go v1.38.60/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2z
github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.40.37/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.44.109 h1:+Na5JPeS0kiEHoBp5Umcuuf+IDqXqD0lXnM920E31YI=
github.com/aws/aws-sdk-go v1.44.109/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.44.146 h1:7YdGgPxDPRJu/yYffzZp/H7yHzQ6AqmuNFZPYraaN8I=
github.com/aws/aws-sdk-go v1.44.146/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
github.com/aws/aws-sdk-go-v2 v1.16.2 h1:fqlCk6Iy3bnCumtrLz9r3mJ/2gUT0pJ0wLFVIdWh+JA=
@@ -2865,8 +2866,8 @@ golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220909164309-bea034e7d591 h1:D0B/7al0LLrVC8aWF4+oxpv/m8bc7ViFfVS8/gXGdqI=
golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -3087,14 +3088,16 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@@ -68,6 +68,7 @@ export interface FeatureToggles {
objectStore?: boolean;
traceqlEditor?: boolean;
flameGraph?: boolean;
cloudWatchCrossAccountQuerying?: boolean;
redshiftAsyncQueryDataSupport?: boolean;
athenaAsyncQueryDataSupport?: boolean;
increaseInMemDatabaseQueryCache?: boolean;

View File

@@ -295,6 +295,11 @@ var (
Description: "Show the flame graph",
State: FeatureStateAlpha,
},
{
Name: "cloudWatchCrossAccountQuerying",
Description: "Use cross-account querying in CloudWatch datasource",
State: FeatureStateAlpha,
},
{
Name: "redshiftAsyncQueryDataSupport",
Description: "Enable async query data support for Redshift",

View File

@@ -215,6 +215,10 @@ const (
// Show the flame graph
FlagFlameGraph = "flameGraph"
// FlagCloudWatchCrossAccountQuerying
// Use cross-account querying in CloudWatch datasource
FlagCloudWatchCrossAccountQuerying = "cloudWatchCrossAccountQuerying"
// FlagRedshiftAsyncQueryDataSupport
// Enable async query data support for Redshift
FlagRedshiftAsyncQueryDataSupport = "redshiftAsyncQueryDataSupport"

View File

@@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
)
type metricsClient struct {
@@ -17,16 +18,20 @@ func NewMetricsClient(api models.CloudWatchMetricsAPIProvider, config *setting.C
return &metricsClient{CloudWatchMetricsAPIProvider: api, config: config}
}
func (l *metricsClient) ListMetricsWithPageLimit(params *cloudwatch.ListMetricsInput) ([]*cloudwatch.Metric, error) {
var cloudWatchMetrics []*cloudwatch.Metric
func (l *metricsClient) ListMetricsWithPageLimit(params *cloudwatch.ListMetricsInput) ([]resources.MetricResponse, error) {
var cloudWatchMetrics []resources.MetricResponse
pageNum := 0
err := l.ListMetricsPages(params, func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool {
pageNum++
metrics.MAwsCloudWatchListMetrics.Inc()
metrics, err := awsutil.ValuesAtPath(page, "Metrics")
if err == nil {
for _, metric := range metrics {
cloudWatchMetrics = append(cloudWatchMetrics, metric.(*cloudwatch.Metric))
for idx, metric := range metrics {
metric := resources.MetricResponse{Metric: metric.(*cloudwatch.Metric)}
if len(page.OwningAccounts) >= idx && params.IncludeLinkedAccounts != nil && *params.IncludeLinkedAccounts {
metric.AccountId = page.OwningAccounts[idx]
}
cloudWatchMetrics = append(cloudWatchMetrics, metric)
}
}
return !lastPage && pageNum < l.config.AWSListMetricsPageLimit

View File

@@ -7,6 +7,7 @@ import (
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -46,4 +47,33 @@ func TestMetricsClient(t *testing.T) {
assert.Equal(t, len(metrics), len(response))
})
t.Run("Should return account id in case IncludeLinkedAccounts is set to true", func(t *testing.T) {
fakeApi := &mocks.FakeMetricsAPI{Metrics: []*cloudwatch.Metric{
{MetricName: aws.String("Test_MetricName1")},
{MetricName: aws.String("Test_MetricName2")},
{MetricName: aws.String("Test_MetricName3")},
}, OwningAccounts: []*string{aws.String("1234567890"), aws.String("1234567890"), aws.String("1234567895")}}
client := NewMetricsClient(fakeApi, &setting.Cfg{AWSListMetricsPageLimit: 100})
response, err := client.ListMetricsWithPageLimit(&cloudwatch.ListMetricsInput{IncludeLinkedAccounts: aws.Bool(true)})
require.NoError(t, err)
expected := []resources.MetricResponse{
{Metric: &cloudwatch.Metric{MetricName: aws.String("Test_MetricName1")}, AccountId: stringPtr("1234567890")},
{Metric: &cloudwatch.Metric{MetricName: aws.String("Test_MetricName2")}, AccountId: stringPtr("1234567890")},
{Metric: &cloudwatch.Metric{MetricName: aws.String("Test_MetricName3")}, AccountId: stringPtr("1234567895")},
}
assert.Equal(t, expected, response)
})
t.Run("Should not return account id in case IncludeLinkedAccounts is set to false", func(t *testing.T) {
fakeApi := &mocks.FakeMetricsAPI{Metrics: []*cloudwatch.Metric{{MetricName: aws.String("Test_MetricName1")}}, OwningAccounts: []*string{aws.String("1234567890")}}
client := NewMetricsClient(fakeApi, &setting.Cfg{AWSListMetricsPageLimit: 100})
response, err := client.ListMetricsWithPageLimit(&cloudwatch.ListMetricsInput{IncludeLinkedAccounts: aws.Bool(false)})
require.NoError(t, err)
assert.Nil(t, response[0].AccountId)
})
}
func stringPtr(s string) *string { return &s }

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"time"
@@ -18,6 +17,7 @@ import (
"github.com/aws/aws-sdk-go/service/cloudwatchlogs/cloudwatchlogsiface"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
"github.com/aws/aws-sdk-go/service/oam"
"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface"
"github.com/grafana/grafana-aws-sdk/pkg/awsds"
@@ -26,7 +26,6 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
"github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -122,8 +121,11 @@ func (e *cloudWatchExecutor) getRequestContext(pluginCtx backend.PluginContext,
return models.RequestContext{}, err
}
return models.RequestContext{
OAMClientProvider: NewOAMAPI(sess),
MetricsClientProvider: clients.NewMetricsClient(NewMetricsAPI(sess), e.cfg),
LogsAPIProvider: NewLogsAPI(sess),
Settings: instance.Settings,
Features: e.features,
}, nil
}
@@ -178,11 +180,12 @@ func (e *cloudWatchExecutor) checkHealthMetrics(pluginCtx backend.PluginContext)
}
func (e *cloudWatchExecutor) checkHealthLogs(pluginCtx backend.PluginContext) error {
parameters := url.Values{
"limit": []string{"1"},
session, err := e.newSession(pluginCtx, defaultRegion)
if err != nil {
return err
}
_, err := e.handleGetLogGroups(pluginCtx, parameters)
logsClient := NewLogsAPI(session)
_, err = logsClient.DescribeLogGroups(&cloudwatchlogs.DescribeLogGroupsInput{Limit: aws.Int64(1)})
return err
}
@@ -423,6 +426,20 @@ var NewMetricsAPI = func(sess *session.Session) models.CloudWatchMetricsAPIProvi
return cloudwatch.New(sess)
}
// NewLogsAPI is a CloudWatch logs api factory.
//
// Stubbable by tests.
var NewLogsAPI = func(sess *session.Session) models.CloudWatchLogsAPIProvider {
return cloudwatchlogs.New(sess)
}
// NewOAMAPI is a CloudWatch OAM api factory.
//
// Stubbable by tests.
var NewOAMAPI = func(sess *session.Session) models.OAMClientProvider {
return oam.New(sess)
}
// NewCWClient is a CloudWatch client factory.
//
// Stubbable by tests.

View File

@@ -24,7 +24,9 @@ import (
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@@ -97,16 +99,18 @@ func TestNewInstanceSettings(t *testing.T) {
func Test_CheckHealth(t *testing.T) {
origNewMetricsAPI := NewMetricsAPI
origNewCWLogsClient := NewCWLogsClient
origNewLogsAPI := NewLogsAPI
t.Cleanup(func() {
NewMetricsAPI = origNewMetricsAPI
NewCWLogsClient = origNewCWLogsClient
NewLogsAPI = origNewLogsAPI
})
var client fakeCheckHealthClient
NewMetricsAPI = func(sess *session.Session) models.CloudWatchMetricsAPIProvider {
return client
}
NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI {
NewLogsAPI = func(sess *session.Session) models.CloudWatchLogsAPIProvider {
return client
}
@@ -536,16 +540,86 @@ func TestQuery_ResourceRequest_DescribeLogGroups(t *testing.T) {
})
}
func TestQuery_ResourceRequest_DescribeLogGroups_with_CrossAccountQuerying(t *testing.T) {
sender := &mockedCallResourceResponseSenderForOauth{}
origNewMetricsAPI := NewMetricsAPI
origNewOAMAPI := NewOAMAPI
origNewLogsAPI := NewLogsAPI
NewMetricsAPI = func(sess *session.Session) models.CloudWatchMetricsAPIProvider { return nil }
NewOAMAPI = func(sess *session.Session) models.OAMClientProvider { return nil }
t.Cleanup(func() {
NewOAMAPI = origNewOAMAPI
NewMetricsAPI = origNewMetricsAPI
NewLogsAPI = origNewLogsAPI
})
var logsApi mocks.LogsAPI
NewLogsAPI = func(sess *session.Session) models.CloudWatchLogsAPIProvider {
return &logsApi
}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return DataSource{Settings: models.CloudWatchSettings{}}, nil
})
t.Run("maps log group api response to resource response of describe-log-groups", func(t *testing.T) {
logsApi = mocks.LogsAPI{}
logsApi.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{
LogGroups: []*cloudwatchlogs.LogGroup{
{Arn: aws.String("arn:aws:logs:us-east-1:111:log-group:group_a"), LogGroupName: aws.String("group_a")},
},
}, nil)
req := &backend.CallResourceRequest{
Method: "GET",
Path: `/describe-log-groups?logGroupPattern=some-pattern&accountId=some-account-id`,
PluginContext: backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ID: 0},
PluginID: "cloudwatch",
},
}
executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying))
err := executor.CallResource(context.Background(), req, sender)
assert.NoError(t, err)
assert.JSONEq(t, `[
{
"accountId":"111",
"value":{
"arn":"arn:aws:logs:us-east-1:111:log-group:group_a",
"name":"group_a"
}
}
]`, string(sender.Response.Body))
logsApi.AssertCalled(t, "DescribeLogGroups",
&cloudwatchlogs.DescribeLogGroupsInput{
AccountIdentifiers: []*string{utils.Pointer("some-account-id")},
IncludeLinkedAccounts: utils.Pointer(true),
Limit: utils.Pointer(int64(50)),
LogGroupNamePrefix: utils.Pointer("some-pattern"),
})
})
}
func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) {
sender := &mockedCallResourceResponseSenderForOauth{}
origNewMetricsAPI := NewMetricsAPI
origNewOAMAPI := NewOAMAPI
origNewLogsAPI := NewLogsAPI
NewOAMAPI = func(sess *session.Session) models.OAMClientProvider { return nil }
NewLogsAPI = func(sess *session.Session) models.CloudWatchLogsAPIProvider { return nil }
t.Cleanup(func() {
NewOAMAPI = origNewOAMAPI
NewMetricsAPI = origNewMetricsAPI
NewLogsAPI = origNewLogsAPI
})
var api mocks.FakeMetricsAPI
NewMetricsAPI = func(sess *session.Session) models.CloudWatchMetricsAPIProvider {
return &api
}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return DataSource{Settings: models.CloudWatchSettings{}}, nil
})
@@ -580,10 +654,10 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) {
sent := sender.Response
require.NotNil(t, sent)
require.Equal(t, http.StatusOK, sent.Status)
res := []string{}
res := []resources.ResourceResponse[string]{}
err = json.Unmarshal(sent.Body, &res)
require.Nil(t, err)
assert.Equal(t, []string{"Value1", "Value2", "Value7"}, res)
assert.Equal(t, []resources.ResourceResponse[string]{{Value: "Value1"}, {Value: "Value2"}, {Value: "Value7"}}, res)
})
t.Run("Should handle dimension key filter query and return keys from the api", func(t *testing.T) {
@@ -616,10 +690,10 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) {
sent := sender.Response
require.NotNil(t, sent)
require.Equal(t, http.StatusOK, sent.Status)
res := []string{}
res := []resources.ResourceResponse[string]{}
err = json.Unmarshal(sent.Body, &res)
require.Nil(t, err)
assert.Equal(t, []string{"Test_DimensionName1", "Test_DimensionName2", "Test_DimensionName4", "Test_DimensionName5"}, res)
assert.Equal(t, []resources.ResourceResponse[string]{{Value: "Test_DimensionName1"}, {Value: "Test_DimensionName2"}, {Value: "Test_DimensionName4"}, {Value: "Test_DimensionName5"}}, res)
})
t.Run("Should handle standard dimension key query and return hard coded keys", func(t *testing.T) {
@@ -640,10 +714,10 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) {
sent := sender.Response
require.NotNil(t, sent)
require.Equal(t, http.StatusOK, sent.Status)
res := []string{}
res := []resources.ResourceResponse[string]{}
err = json.Unmarshal(sent.Body, &res)
require.Nil(t, err)
assert.Equal(t, []string{"ClientId", "DomainName"}, res)
assert.Equal(t, []resources.ResourceResponse[string]{{Value: "ClientId"}, {Value: "DomainName"}}, res)
})
t.Run("Should handle custom namespace dimension key query and return hard coded keys", func(t *testing.T) {
@@ -664,10 +738,10 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) {
sent := sender.Response
require.NotNil(t, sent)
require.Equal(t, http.StatusOK, sent.Status)
res := []string{}
res := []resources.ResourceResponse[string]{}
err = json.Unmarshal(sent.Body, &res)
require.Nil(t, err)
assert.Equal(t, []string{"ClientId", "DomainName"}, res)
assert.Equal(t, []resources.ResourceResponse[string]{{Value: "ClientId"}, {Value: "DomainName"}}, res)
})
t.Run("Should handle custom namespace metrics query and return metrics from api", func(t *testing.T) {
@@ -700,10 +774,10 @@ func Test_CloudWatch_CallResource_Integration_Test(t *testing.T) {
sent := sender.Response
require.NotNil(t, sent)
require.Equal(t, http.StatusOK, sent.Status)
res := []resources.Metric{}
res := []resources.ResourceResponse[resources.Metric]{}
err = json.Unmarshal(sent.Body, &res)
require.Nil(t, err)
assert.Equal(t, []resources.Metric{{Name: "Test_MetricName1", Namespace: "AWS/EC2"}, {Name: "Test_MetricName2", Namespace: "AWS/EC2"}, {Name: "Test_MetricName3", Namespace: "AWS/ECS"}, {Name: "Test_MetricName10", Namespace: "AWS/ECS"}, {Name: "Test_MetricName4", Namespace: "AWS/ECS"}, {Name: "Test_MetricName5", Namespace: "AWS/Redshift"}}, res)
assert.Equal(t, []resources.ResourceResponse[resources.Metric]{{Value: resources.Metric{Name: "Test_MetricName1", Namespace: "AWS/EC2"}}, {Value: resources.Metric{Name: "Test_MetricName2", Namespace: "AWS/EC2"}}, {Value: resources.Metric{Name: "Test_MetricName3", Namespace: "AWS/ECS"}}, {Value: resources.Metric{Name: "Test_MetricName10", Namespace: "AWS/ECS"}}, {Value: resources.Metric{Name: "Test_MetricName4", Namespace: "AWS/ECS"}}, {Value: resources.Metric{Name: "Test_MetricName5", Namespace: "AWS/Redshift"}}}, res)
})
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"math"
"sort"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
@@ -17,6 +18,7 @@ import (
"golang.org/x/sync/errgroup"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
const (
@@ -40,6 +42,7 @@ type LogQueryJson struct {
EndTime *int64
LogGroupName string
LogGroupNames []string
LogGroups []suggestData
LogGroupNamePrefix string
LogStreamName string
StartFromHead bool
@@ -224,15 +227,31 @@ func (e *cloudWatchExecutor) executeStartQuery(ctx context.Context, logsClient c
// StartTime is effectively floored while here EndTime is ceiled and so we should get the logs user wants
// and also a little bit more but as CW logs accept only seconds as integers there is not much to do about
// that.
EndTime: aws.Int64(int64(math.Ceil(float64(endTime.UnixNano()) / 1e9))),
LogGroupNames: aws.StringSlice(parameters.LogGroupNames),
QueryString: aws.String(modifiedQueryString),
EndTime: aws.Int64(int64(math.Ceil(float64(endTime.UnixNano()) / 1e9))),
QueryString: aws.String(modifiedQueryString),
}
if e.features.IsEnabled(featuremgmt.FlagCloudWatchCrossAccountQuerying) {
if parameters.LogGroups != nil && len(parameters.LogGroups) > 0 {
var logGroupIdentifiers []string
for _, lg := range parameters.LogGroups {
arn := lg.Value
// due to a bug in the startQuery api, we remove * from the arn, otherwise it throws an error
logGroupIdentifiers = append(logGroupIdentifiers, strings.TrimSuffix(arn, "*"))
}
startQueryInput.LogGroupIdentifiers = aws.StringSlice(logGroupIdentifiers)
}
}
if startQueryInput.LogGroupIdentifiers == nil {
startQueryInput.LogGroupNames = aws.StringSlice(parameters.LogGroupNames)
}
if parameters.Limit != nil {
startQueryInput.Limit = aws.Int64(*parameters.Limit)
}
logger.Debug("calling startquery with context with input", "input", startQueryInput)
return logsClient.StartQueryWithContext(ctx, startQueryInput)
}

View File

@@ -391,6 +391,78 @@ func Test_executeStartQuery(t *testing.T) {
require.Len(t, cli.calls.startQueryWithContext, 1)
assert.Nil(t, cli.calls.startQueryWithContext[0].Limit)
})
t.Run("attaches logGroupIdentifiers if the crossAccount feature is enabled", func(t *testing.T) {
cli = fakeCWLogsClient{}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return DataSource{Settings: models.CloudWatchSettings{}}, nil
})
executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying))
_, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}},
Queries: []backend.DataQuery{
{
RefID: "A",
TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)},
JSON: json.RawMessage(`{
"type": "logAction",
"subtype": "StartQuery",
"limit": 12,
"queryString":"fields @message",
"logGroups":[{"value": "fakeARN"}]
}`),
},
},
})
assert.NoError(t, err)
assert.Equal(t, []*cloudwatchlogs.StartQueryInput{
{
StartTime: aws.Int64(0),
EndTime: aws.Int64(1),
Limit: aws.Int64(12),
QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"),
LogGroupIdentifiers: []*string{aws.String("fakeARN")},
},
}, cli.calls.startQueryWithContext)
})
t.Run("attaches logGroupIdentifiers if the crossAccount feature is enabled and strips out trailing *", func(t *testing.T) {
cli = fakeCWLogsClient{}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return DataSource{Settings: models.CloudWatchSettings{}}, nil
})
executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying))
_, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}},
Queries: []backend.DataQuery{
{
RefID: "A",
TimeRange: backend.TimeRange{From: time.Unix(0, 0), To: time.Unix(1, 0)},
JSON: json.RawMessage(`{
"type": "logAction",
"subtype": "StartQuery",
"limit": 12,
"queryString":"fields @message",
"logGroups":[{"value": "*fake**ARN*"}]
}`),
},
},
})
assert.NoError(t, err)
assert.Equal(t, []*cloudwatchlogs.StartQueryInput{
{
StartTime: aws.Int64(0),
EndTime: aws.Int64(1),
Limit: aws.Int64(12),
QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"),
LogGroupIdentifiers: []*string{aws.String("*fake**ARN")},
},
}, cli.calls.startQueryWithContext)
})
}
func TestQuery_StopQuery(t *testing.T) {

View File

@@ -50,6 +50,7 @@ func (e *cloudWatchExecutor) buildMetricDataQuery(logger log.Logger, query *mode
})
}
mdq.MetricStat.Stat = aws.String(query.Statistic)
mdq.AccountId = query.AccountId
}
if mdq.Expression != nil {
@@ -98,18 +99,27 @@ func buildSearchExpression(query *models.CloudWatchQuery, stat string) string {
searchTerm = appendSearch(searchTerm, keyFilter)
}
var account string
if query.AccountId != nil && *query.AccountId != "all" {
account = fmt.Sprintf(":aws.AccountId=%q", *query.AccountId)
}
if query.MatchExact {
schema := fmt.Sprintf("%q", query.Namespace)
if len(dimensionNames) > 0 {
sort.Strings(dimensionNames)
schema += fmt.Sprintf(",%s", join(dimensionNames, ",", `"`, `"`))
}
return fmt.Sprintf("REMOVE_EMPTY(SEARCH('{%s} %s', '%s', %s))", schema, searchTerm, stat, strconv.Itoa(query.Period))
schema = fmt.Sprintf("{%s}", schema)
schemaSearchTermAndAccount := strings.TrimSpace(strings.Join([]string{schema, searchTerm, account}, " "))
return fmt.Sprintf("REMOVE_EMPTY(SEARCH('%s', '%s', %s))", schemaSearchTermAndAccount, stat, strconv.Itoa(query.Period))
}
sort.Strings(dimensionNamesWithoutKnownValues)
searchTerm = appendSearch(searchTerm, join(dimensionNamesWithoutKnownValues, " ", `"`, `"`))
return fmt.Sprintf(`REMOVE_EMPTY(SEARCH('Namespace="%s" %s', '%s', %s))`, query.Namespace, searchTerm, stat, strconv.Itoa(query.Period))
namespace := fmt.Sprintf("Namespace=%q", query.Namespace)
namespaceSearchTermAndAccount := strings.TrimSpace(strings.Join([]string{namespace, searchTerm, account}, " "))
return fmt.Sprintf(`REMOVE_EMPTY(SEARCH('%s', '%s', %s))`, namespaceSearchTermAndAccount, stat, strconv.Itoa(query.Period))
}
func escapeDoubleQuotes(arr []string) []string {

View File

@@ -3,11 +3,11 @@ package cloudwatch
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/aws/aws-sdk-go/aws"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMetricDataQueryBuilder(t *testing.T) {
@@ -24,6 +24,27 @@ func TestMetricDataQueryBuilder(t *testing.T) {
assert.Equal(t, query.Namespace, *mdq.MetricStat.Metric.Namespace)
})
t.Run("should pass AccountId in metric stat query", func(t *testing.T) {
executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures())
query := getBaseQuery()
query.MetricEditorMode = models.MetricEditorModeBuilder
query.MetricQueryType = models.MetricQueryTypeSearch
query.AccountId = aws.String("some account id")
mdq, err := executor.buildMetricDataQuery(logger, query)
require.NoError(t, err)
assert.Equal(t, "some account id", *mdq.AccountId)
})
t.Run("should leave AccountId in metric stat query", func(t *testing.T) {
executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures())
query := getBaseQuery()
query.MetricEditorMode = models.MetricEditorModeBuilder
query.MetricQueryType = models.MetricQueryTypeSearch
mdq, err := executor.buildMetricDataQuery(logger, query)
require.NoError(t, err)
assert.Nil(t, mdq.AccountId)
})
t.Run("should use custom built expression", func(t *testing.T) {
executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures())
query := getBaseQuery()
@@ -112,6 +133,42 @@ func TestMetricDataQueryBuilder(t *testing.T) {
assert.Nil(t, mdq.Label)
})
}
t.Run(`should not specify accountId when it is "all"`, func(t *testing.T) {
executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels))
query := &models.CloudWatchQuery{
Namespace: "AWS/EC2",
MetricName: "CPUUtilization",
Statistic: "Average",
Period: 60,
MatchExact: false,
AccountId: aws.String("all"),
}
mdq, err := executor.buildMetricDataQuery(logger, query)
assert.NoError(t, err)
require.Nil(t, mdq.MetricStat)
assert.Equal(t, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization"', 'Average', 60))`, *mdq.Expression)
})
t.Run("should set accountId when it is specified", func(t *testing.T) {
executor := newExecutor(nil, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchDynamicLabels))
query := &models.CloudWatchQuery{
Namespace: "AWS/EC2",
MetricName: "CPUUtilization",
Statistic: "Average",
Period: 60,
MatchExact: false,
AccountId: aws.String("12345"),
}
mdq, err := executor.buildMetricDataQuery(logger, query)
assert.NoError(t, err)
require.Nil(t, mdq.MetricStat)
assert.Equal(t, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" :aws.AccountId="12345"', 'Average', 60))`, *mdq.Expression)
})
})
t.Run("Query should be matched exact", func(t *testing.T) {
@@ -199,6 +256,24 @@ func TestMetricDataQueryBuilder(t *testing.T) {
assert.Equal(t, `REMOVE_EMPTY(SEARCH('{"AWS/EC2","InstanceId","LoadBalancer"} MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3")', 'Average', 300))`, res)
})
t.Run("Query has multiple dimensions and an account Id", func(t *testing.T) {
query := &models.CloudWatchQuery{
Namespace: "AWS/EC2",
MetricName: "CPUUtilization",
Dimensions: map[string][]string{
"LoadBalancer": {"lb1", "lb2", "lb3"},
"InstanceId": {"i-123", "*", "i-789"},
},
Period: 300,
Expression: "",
MatchExact: matchExact,
AccountId: aws.String("some account id"),
}
res := buildSearchExpression(query, "Average")
assert.Equal(t, `REMOVE_EMPTY(SEARCH('{"AWS/EC2","InstanceId","LoadBalancer"} MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3") :aws.AccountId="some account id"', 'Average', 300))`, res)
})
t.Run("Query has a dimension key with a space", func(t *testing.T) {
query := &models.CloudWatchQuery{
Namespace: "AWS/Kafka",
@@ -301,6 +376,24 @@ func TestMetricDataQueryBuilder(t *testing.T) {
res := buildSearchExpression(query, "Average")
assert.Equal(t, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3") "InstanceId"', 'Average', 300))`, res)
})
t.Run("query has multiple dimensions and an account Id", func(t *testing.T) {
query := &models.CloudWatchQuery{
Namespace: "AWS/EC2",
MetricName: "CPUUtilization",
Dimensions: map[string][]string{
"LoadBalancer": {"lb1", "lb2", "lb3"},
"InstanceId": {"i-123", "*", "i-789"},
},
Period: 300,
Expression: "",
MatchExact: matchExact,
AccountId: aws.String("some account id"),
}
res := buildSearchExpression(query, "Average")
assert.Equal(t, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3") "InstanceId" :aws.AccountId="some account id"', 'Average', 300))`, res)
})
})
t.Run("Query has invalid characters in dimension values", func(t *testing.T) {

View File

@@ -17,7 +17,6 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/constants"
)
@@ -316,7 +315,6 @@ func (e *cloudWatchExecutor) handleGetLogGroups(pluginCtx backend.PluginContext,
if err != nil || response == nil {
return nil, err
}
result := make([]suggestData, 0)
for _, logGroup := range response.LogGroups {
logGroupName := *logGroup.LogGroupName

View File

@@ -0,0 +1,16 @@
package mocks
import (
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/stretchr/testify/mock"
)
type AccountsServiceMock struct {
mock.Mock
}
func (a *AccountsServiceMock) GetAccountsForCurrentUserOrRole() ([]resources.ResourceResponse[resources.Account], error) {
args := a.Called()
return args.Get(0).([]resources.ResourceResponse[resources.Account]), args.Error(1)
}

View File

@@ -9,9 +9,8 @@ import (
)
type FakeMetricsAPI struct {
cloudwatchiface.CloudWatchAPI
Metrics []*cloudwatch.Metric
OwningAccounts []*string
MetricsPerPage int
}
@@ -23,7 +22,8 @@ func (c *FakeMetricsAPI) ListMetricsPages(input *cloudwatch.ListMetricsInput, fn
for i, metrics := range chunks {
response := fn(&cloudwatch.ListMetricsOutput{
Metrics: metrics,
Metrics: metrics,
OwningAccounts: c.OwningAccounts,
}, i+1 == len(chunks))
if !response {
break

View File

@@ -9,26 +9,20 @@ type ListMetricsServiceMock struct {
mock.Mock
}
func (a *ListMetricsServiceMock) GetDimensionKeysByDimensionFilter(r resources.DimensionKeysRequest) ([]string, error) {
func (a *ListMetricsServiceMock) GetDimensionKeysByDimensionFilter(r resources.DimensionKeysRequest) ([]resources.ResourceResponse[string], error) {
args := a.Called(r)
return args.Get(0).([]string), args.Error(1)
return args.Get(0).([]resources.ResourceResponse[string]), args.Error(1)
}
func (a *ListMetricsServiceMock) GetDimensionValuesByDimensionFilter(r resources.DimensionValuesRequest) ([]string, error) {
func (a *ListMetricsServiceMock) GetDimensionValuesByDimensionFilter(r resources.DimensionValuesRequest) ([]resources.ResourceResponse[string], error) {
args := a.Called(r)
return args.Get(0).([]string), args.Error(1)
return args.Get(0).([]resources.ResourceResponse[string]), args.Error(1)
}
func (a *ListMetricsServiceMock) GetDimensionKeysByNamespace(namespace string) ([]string, error) {
args := a.Called(namespace)
func (a *ListMetricsServiceMock) GetMetricsByNamespace(r resources.MetricsRequest) ([]resources.ResourceResponse[resources.Metric], error) {
args := a.Called(r)
return args.Get(0).([]string), args.Error(1)
}
func (a *ListMetricsServiceMock) GetMetricsByNamespace(namespace string) ([]resources.Metric, error) {
args := a.Called(namespace)
return args.Get(0).([]resources.Metric), args.Error(1)
return args.Get(0).([]resources.ResourceResponse[resources.Metric]), args.Error(1)
}

View File

@@ -0,0 +1,37 @@
package mocks
import (
"github.com/aws/aws-sdk-go/service/cloudwatchlogs"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/stretchr/testify/mock"
)
type LogsAPI struct {
mock.Mock
}
func (l *LogsAPI) DescribeLogGroups(input *cloudwatchlogs.DescribeLogGroupsInput) (*cloudwatchlogs.DescribeLogGroupsOutput, error) {
args := l.Called(input)
return args.Get(0).(*cloudwatchlogs.DescribeLogGroupsOutput), args.Error(1)
}
type LogsService struct {
mock.Mock
}
func (l *LogsService) GetLogGroups(request resources.LogGroupsRequest) ([]resources.ResourceResponse[resources.LogGroup], error) {
args := l.Called(request)
return args.Get(0).([]resources.ResourceResponse[resources.LogGroup]), args.Error(1)
}
type MockFeatures struct {
mock.Mock
}
func (f *MockFeatures) IsEnabled(feature string) bool {
args := f.Called(feature)
return args.Bool(0)
}

View File

@@ -2,6 +2,7 @@ package mocks
import (
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/stretchr/testify/mock"
)
@@ -9,7 +10,7 @@ type FakeMetricsClient struct {
mock.Mock
}
func (m *FakeMetricsClient) ListMetricsWithPageLimit(params *cloudwatch.ListMetricsInput) ([]*cloudwatch.Metric, error) {
func (m *FakeMetricsClient) ListMetricsWithPageLimit(params *cloudwatch.ListMetricsInput) ([]resources.MetricResponse, error) {
args := m.Called(params)
return args.Get(0).([]*cloudwatch.Metric), args.Error(1)
return args.Get(0).([]resources.MetricResponse), args.Error(1)
}

View File

@@ -0,0 +1,20 @@
package mocks
import (
"github.com/aws/aws-sdk-go/service/oam"
"github.com/stretchr/testify/mock"
)
type FakeOAMClient struct {
mock.Mock
}
func (o *FakeOAMClient) ListSinks(input *oam.ListSinksInput) (*oam.ListSinksOutput, error) {
args := o.Called(input)
return args.Get(0).(*oam.ListSinksOutput), args.Error(1)
}
func (o *FakeOAMClient) ListAttachedLinks(input *oam.ListAttachedLinksInput) (*oam.ListAttachedLinksOutput, error) {
args := o.Called(input)
return args.Get(0).(*oam.ListAttachedLinksOutput), args.Error(1)
}

View File

@@ -1,21 +1,55 @@
package models
import (
"net/url"
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/aws/aws-sdk-go/service/cloudwatchlogs"
"github.com/aws/aws-sdk-go/service/oam"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
)
type RequestContextFactoryFunc func(pluginCtx backend.PluginContext, region string) (reqCtx RequestContext, err error)
type RouteHandlerFunc func(pluginCtx backend.PluginContext, reqContextFactory RequestContextFactoryFunc, parameters url.Values) ([]byte, *HttpError)
type RequestContext struct {
MetricsClientProvider MetricsClientProvider
LogsAPIProvider CloudWatchLogsAPIProvider
OAMClientProvider OAMClientProvider
Settings CloudWatchSettings
Features featuremgmt.FeatureToggles
}
type ListMetricsProvider interface {
GetDimensionKeysByDimensionFilter(resources.DimensionKeysRequest) ([]string, error)
GetDimensionKeysByNamespace(string) ([]string, error)
GetDimensionValuesByDimensionFilter(resources.DimensionValuesRequest) ([]string, error)
GetMetricsByNamespace(namespace string) ([]resources.Metric, error)
GetDimensionKeysByDimensionFilter(resources.DimensionKeysRequest) ([]resources.ResourceResponse[string], error)
GetDimensionValuesByDimensionFilter(resources.DimensionValuesRequest) ([]resources.ResourceResponse[string], error)
GetMetricsByNamespace(r resources.MetricsRequest) ([]resources.ResourceResponse[resources.Metric], error)
}
type MetricsClientProvider interface {
ListMetricsWithPageLimit(params *cloudwatch.ListMetricsInput) ([]*cloudwatch.Metric, error)
ListMetricsWithPageLimit(params *cloudwatch.ListMetricsInput) ([]resources.MetricResponse, error)
}
type CloudWatchMetricsAPIProvider interface {
ListMetricsPages(*cloudwatch.ListMetricsInput, func(*cloudwatch.ListMetricsOutput, bool) bool) error
}
type CloudWatchLogsAPIProvider interface {
DescribeLogGroups(*cloudwatchlogs.DescribeLogGroupsInput) (*cloudwatchlogs.DescribeLogGroupsOutput, error)
}
type OAMClientProvider interface {
ListSinks(*oam.ListSinksInput) (*oam.ListSinksOutput, error)
ListAttachedLinks(*oam.ListAttachedLinksInput) (*oam.ListAttachedLinksOutput, error)
}
type LogGroupsProvider interface {
GetLogGroups(request resources.LogGroupsRequest) ([]resources.ResourceResponse[resources.LogGroup], error)
}
type AccountsProvider interface {
GetAccountsForCurrentUserOrRole() ([]resources.ResourceResponse[resources.Account], error)
}

View File

@@ -60,6 +60,7 @@ type CloudWatchQuery struct {
TimezoneUTCOffset string
MetricQueryType MetricQueryType
MetricEditorMode MetricEditorMode
AccountId *string
}
func (q *CloudWatchQuery) GetGMDAPIMode(logger log.Logger) GMDApiMode {
@@ -95,6 +96,10 @@ func (q *CloudWatchQuery) IsInferredSearchExpression() bool {
return false
}
if q.AccountId != nil && *q.AccountId == "all" {
return true
}
if len(q.Dimensions) == 0 {
return !q.MatchExact
}
@@ -167,6 +172,9 @@ func (q *CloudWatchQuery) BuildDeepLink(startTime time.Time, endTime time.Time,
if dynamicLabelEnabled {
metricStatMeta.Label = q.Label
}
if q.AccountId != nil {
metricStatMeta.AccountId = *q.AccountId
}
metricStat = append(metricStat, metricStatMeta)
link.Metrics = []interface{}{metricStat}
}
@@ -214,11 +222,13 @@ type metricsDataQuery struct {
QueryType string `json:"type"`
Hide *bool `json:"hide"`
Alias string `json:"alias"`
AccountId *string `json:"accountId"`
}
// ParseMetricDataQueries decodes the metric data queries json, validates, sets default values and returns an array of CloudWatchQueries.
// The CloudWatchQuery has a 1 to 1 mapping to a query editor row
func ParseMetricDataQueries(dataQueries []backend.DataQuery, startTime time.Time, endTime time.Time, dynamicLabelsEnabled bool) ([]*CloudWatchQuery, error) {
func ParseMetricDataQueries(dataQueries []backend.DataQuery, startTime time.Time, endTime time.Time, dynamicLabelsEnabled,
crossAccountQueryingEnabled bool) ([]*CloudWatchQuery, error) {
var metricDataQueries = make(map[string]metricsDataQuery)
for _, query := range dataQueries {
var metricsDataQuery metricsDataQuery
@@ -250,7 +260,7 @@ func ParseMetricDataQueries(dataQueries []backend.DataQuery, startTime time.Time
Expression: mdq.Expression,
}
if err := cwQuery.validateAndSetDefaults(refId, mdq, startTime, endTime); err != nil {
if err := cwQuery.validateAndSetDefaults(refId, mdq, startTime, endTime, crossAccountQueryingEnabled); err != nil {
return nil, &QueryError{Err: err, RefID: refId}
}
@@ -267,7 +277,8 @@ func (q *CloudWatchQuery) migrateLegacyQuery(query metricsDataQuery, dynamicLabe
q.Label = getLabel(query, dynamicLabelsEnabled)
}
func (q *CloudWatchQuery) validateAndSetDefaults(refId string, metricsDataQuery metricsDataQuery, startTime, endTime time.Time) error {
func (q *CloudWatchQuery) validateAndSetDefaults(refId string, metricsDataQuery metricsDataQuery, startTime, endTime time.Time,
crossAccountQueryingEnabled bool) error {
if metricsDataQuery.Statistic == nil && metricsDataQuery.Statistics == nil {
return fmt.Errorf("query must have either statistic or statistics field")
}
@@ -283,6 +294,10 @@ func (q *CloudWatchQuery) validateAndSetDefaults(refId string, metricsDataQuery
return fmt.Errorf("failed to parse dimensions: %v", err)
}
if crossAccountQueryingEnabled {
q.AccountId = metricsDataQuery.AccountId
}
if metricsDataQuery.Id == "" {
// Why not just use refId if id is not specified in the frontend? When specifying an id in the editor,
// and alphabetical must be used. The id must be unique, so if an id like for example a, b or c would be used,

View File

@@ -107,6 +107,53 @@ func TestCloudWatchQuery(t *testing.T) {
require.NoError(t, err)
assert.NotContains(t, deepLink, "label")
})
t.Run("includes account id in case its a metric stat query and an account id is set", func(t *testing.T) {
startTime := time.Now()
endTime := startTime.Add(2 * time.Hour)
query := &CloudWatchQuery{
RefId: "A",
Region: "us-east-1",
Expression: "",
Statistic: "Average",
Period: 300,
Id: "id1",
MatchExact: true,
AccountId: pointer("123456789"),
Label: "${PROP('Namespace')}",
Dimensions: map[string][]string{
"InstanceId": {"i-12345678"},
},
MetricQueryType: MetricQueryTypeSearch,
MetricEditorMode: MetricEditorModeBuilder,
}
deepLink, err := query.BuildDeepLink(startTime, endTime, false)
require.NoError(t, err)
assert.Contains(t, deepLink, "accountId%22%3A%22123456789")
})
t.Run("does not include account id in case its not a metric stat query", func(t *testing.T) {
startTime := time.Now()
endTime := startTime.Add(2 * time.Hour)
query := &CloudWatchQuery{
RefId: "A",
Region: "us-east-1",
Statistic: "Average",
Expression: "SEARCH(someexpression)",
AccountId: pointer("123456789"),
Period: 300,
Id: "id1",
MatchExact: true,
Label: "${PROP('Namespace')}",
MetricQueryType: MetricQueryTypeSearch,
MetricEditorMode: MetricEditorModeRaw,
}
deepLink, err := query.BuildDeepLink(startTime, endTime, false)
require.NoError(t, err)
assert.NotContains(t, deepLink, "accountId%22%3A%22123456789")
})
})
t.Run("SEARCH(someexpression) was specified in the query editor", func(t *testing.T) {
@@ -269,7 +316,7 @@ func TestRequestParser(t *testing.T) {
},
}
migratedQueries, err := ParseMetricDataQueries(oldQuery, time.Now(), time.Now(), false)
migratedQueries, err := ParseMetricDataQueries(oldQuery, time.Now(), time.Now(), false, false)
assert.NoError(t, err)
require.Len(t, migratedQueries, 1)
require.NotNil(t, migratedQueries[0])
@@ -300,7 +347,7 @@ func TestRequestParser(t *testing.T) {
},
}
results, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
results, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
require.NoError(t, err)
require.Len(t, results, 1)
res := results[0]
@@ -343,7 +390,7 @@ func TestRequestParser(t *testing.T) {
},
}
results, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
results, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
assert.NoError(t, err)
require.Len(t, results, 1)
res := results[0]
@@ -376,7 +423,7 @@ func TestRequestParser(t *testing.T) {
},
}
_, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
_, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
require.Error(t, err)
assert.Equal(t, `error parsing query "", failed to parse dimensions: unknown type as dimension value`, err.Error())
@@ -405,7 +452,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
},
}
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
assert.NoError(t, err)
require.Len(t, res, 1)
require.NotNil(t, res[0])
@@ -437,7 +484,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
to := time.Now()
from := to.Local().Add(time.Minute * time.Duration(5))
res, err := ParseMetricDataQueries(query, from, to, false)
res, err := ParseMetricDataQueries(query, from, to, false, false)
require.NoError(t, err)
require.Len(t, res, 1)
assert.Equal(t, 60, res[0].Period)
@@ -447,7 +494,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
to := time.Now()
from := to.AddDate(0, 0, -1)
res, err := ParseMetricDataQueries(query, from, to, false)
res, err := ParseMetricDataQueries(query, from, to, false, false)
require.NoError(t, err)
require.Len(t, res, 1)
assert.Equal(t, 60, res[0].Period)
@@ -456,7 +503,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
t.Run("Time range is 2 days", func(t *testing.T) {
to := time.Now()
from := to.AddDate(0, 0, -2)
res, err := ParseMetricDataQueries(query, from, to, false)
res, err := ParseMetricDataQueries(query, from, to, false, false)
require.NoError(t, err)
require.Len(t, res, 1)
assert.Equal(t, 300, res[0].Period)
@@ -466,7 +513,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
to := time.Now()
from := to.AddDate(0, 0, -7)
res, err := ParseMetricDataQueries(query, from, to, false)
res, err := ParseMetricDataQueries(query, from, to, false, false)
require.NoError(t, err)
require.Len(t, res, 1)
assert.Equal(t, 900, res[0].Period)
@@ -476,7 +523,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
to := time.Now()
from := to.AddDate(0, 0, -30)
res, err := ParseMetricDataQueries(query, from, to, false)
res, err := ParseMetricDataQueries(query, from, to, false, false)
require.NoError(t, err)
require.Len(t, res, 1)
assert.Equal(t, 3600, res[0].Period)
@@ -486,7 +533,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
to := time.Now()
from := to.AddDate(0, 0, -90)
res, err := ParseMetricDataQueries(query, from, to, false)
res, err := ParseMetricDataQueries(query, from, to, false, false)
require.NoError(t, err)
require.Len(t, res, 1)
assert.Equal(t, 21600, res[0].Period)
@@ -496,7 +543,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
to := time.Now()
from := to.AddDate(-1, 0, 0)
res, err := ParseMetricDataQueries(query, from, to, false)
res, err := ParseMetricDataQueries(query, from, to, false, false)
require.Nil(t, err)
require.Len(t, res, 1)
assert.Equal(t, 21600, res[0].Period)
@@ -506,7 +553,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
to := time.Now()
from := to.AddDate(-2, 0, 0)
res, err := ParseMetricDataQueries(query, from, to, false)
res, err := ParseMetricDataQueries(query, from, to, false, false)
require.NoError(t, err)
require.Len(t, res, 1)
assert.Equal(t, 86400, res[0].Period)
@@ -515,7 +562,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
t.Run("Time range is 2 days, but 16 days ago", func(t *testing.T) {
to := time.Now().AddDate(0, 0, -14)
from := to.AddDate(0, 0, -2)
res, err := ParseMetricDataQueries(query, from, to, false)
res, err := ParseMetricDataQueries(query, from, to, false, false)
require.NoError(t, err)
require.Len(t, res, 1)
assert.Equal(t, 300, res[0].Period)
@@ -524,7 +571,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
t.Run("Time range is 2 days, but 90 days ago", func(t *testing.T) {
to := time.Now().AddDate(0, 0, -88)
from := to.AddDate(0, 0, -2)
res, err := ParseMetricDataQueries(query, from, to, false)
res, err := ParseMetricDataQueries(query, from, to, false, false)
require.NoError(t, err)
require.Len(t, res, 1)
assert.Equal(t, 3600, res[0].Period)
@@ -533,7 +580,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
t.Run("Time range is 2 days, but 456 days ago", func(t *testing.T) {
to := time.Now().AddDate(0, 0, -454)
from := to.AddDate(0, 0, -2)
res, err := ParseMetricDataQueries(query, from, to, false)
res, err := ParseMetricDataQueries(query, from, to, false, false)
require.NoError(t, err)
require.Len(t, res, 1)
assert.Equal(t, 21600, res[0].Period)
@@ -548,7 +595,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
}`),
},
}
_, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
_, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
require.Error(t, err)
assert.Equal(t, `error parsing query "", failed to parse period as duration: time: invalid duration "invalid"`, err.Error())
})
@@ -563,7 +610,7 @@ func Test_ParseMetricDataQueries_periods(t *testing.T) {
},
}
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
assert.NoError(t, err)
require.Len(t, res, 1)
@@ -620,6 +667,18 @@ func Test_ParseMetricDataQueries_query_type_and_metric_editor_mode_and_GMD_query
expectedMetricEditorMode: dummyTestEditorMode,
expectedGMDApiMode: GMDApiModeMetricStat,
},
"no dimensions, matchExact is false": {
extraDataQueryJson: `"matchExact":false,`,
expectedMetricQueryType: MetricQueryTypeSearch,
expectedMetricEditorMode: MetricEditorModeBuilder,
expectedGMDApiMode: GMDApiModeInferredSearchExpression,
},
"query metricQueryType": {
extraDataQueryJson: `"metricQueryType":1,`,
expectedMetricQueryType: MetricQueryTypeQuery,
expectedMetricEditorMode: MetricEditorModeBuilder,
expectedGMDApiMode: GMDApiModeSQLExpression,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
@@ -638,7 +697,7 @@ func Test_ParseMetricDataQueries_query_type_and_metric_editor_mode_and_GMD_query
),
},
}
res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), false)
res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), false, false)
require.NoError(t, err)
require.Len(t, res, 1)
require.NotNil(t, res[0])
@@ -664,7 +723,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) {
}`),
},
}
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
require.NoError(t, err)
require.Len(t, res, 1)
require.NotNil(t, res[0])
@@ -685,7 +744,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) {
}`),
},
}
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
require.NoError(t, err)
require.Len(t, res, 1)
require.NotNil(t, res[0])
@@ -706,7 +765,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) {
}`),
},
}
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
require.NoError(t, err)
require.Len(t, res, 1)
require.NotNil(t, res[0])
@@ -725,7 +784,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) {
}`),
},
}
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
require.NoError(t, err)
require.Len(t, res, 1)
require.NotNil(t, res[0])
@@ -746,7 +805,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) {
}`),
},
}
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
require.NoError(t, err)
require.Len(t, res, 1)
require.NotNil(t, res[0])
@@ -767,7 +826,7 @@ func Test_ParseMetricDataQueries_hide_and_ReturnData(t *testing.T) {
}`),
},
}
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
require.NoError(t, err)
require.Len(t, res, 1)
require.NotNil(t, res[0])
@@ -790,7 +849,7 @@ func Test_ParseMetricDataQueries_ID(t *testing.T) {
}`),
},
}
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
require.NoError(t, err)
require.Len(t, res, 1)
require.NotNil(t, res[0])
@@ -811,7 +870,7 @@ func Test_ParseMetricDataQueries_ID(t *testing.T) {
}`),
},
}
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false)
res, err := ParseMetricDataQueries(query, time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), false, false)
require.NoError(t, err)
require.Len(t, res, 1)
require.NotNil(t, res[0])
@@ -838,7 +897,7 @@ func Test_ParseMetricDataQueries_sets_label_when_label_is_present_in_json_query(
},
}
res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), true)
res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), true, false)
assert.NoError(t, err)
require.Len(t, res, 1)
require.NotNil(t, res[0])
@@ -902,7 +961,7 @@ func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) {
},
}
res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), true)
res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), true, false)
assert.NoError(t, err)
require.Len(t, res, 1)
@@ -949,7 +1008,7 @@ func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) {
},
}
res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), true)
res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), true, false)
assert.NoError(t, err)
require.Len(t, res, 2)
@@ -1019,7 +1078,7 @@ func Test_ParseMetricDataQueries_migrate_alias_to_label(t *testing.T) {
}`, tc.labelJson)),
},
}
res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), tc.dynamicLabelsFeatureToggleEnabled)
res, err := ParseMetricDataQueries(query, time.Now(), time.Now(), tc.dynamicLabelsFeatureToggleEnabled, false)
assert.NoError(t, err)
require.Len(t, res, 1)
@@ -1046,7 +1105,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE
{
JSON: []byte("{}"),
},
}, time.Now(), time.Now(), false)
}, time.Now(), time.Now(), false, false)
assert.Error(t, err)
assert.Equal(t, `error parsing query "", query must have either statistic or statistics field`, err.Error())
@@ -1059,7 +1118,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE
{
JSON: []byte(`{"type":"some other type", "statistic":"Average", "matchExact":false}`),
},
}, time.Now(), time.Now(), false)
}, time.Now(), time.Now(), false, false)
assert.NoError(t, err)
assert.Empty(t, actual)
@@ -1071,7 +1130,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE
{
JSON: []byte(`{"statistic":"Average"}`),
},
}, time.Now(), time.Now(), false)
}, time.Now(), time.Now(), false, false)
assert.NoError(t, err)
assert.NotEmpty(t, actual)
@@ -1083,7 +1142,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE
{
JSON: []byte(`{"statistic":"Average"}`),
},
}, time.Now(), time.Now(), false)
}, time.Now(), time.Now(), false, false)
assert.NoError(t, err)
assert.Len(t, actual, 1)
@@ -1097,7 +1156,7 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE
{
JSON: []byte(`{"statistic":"Average","matchExact":false}`),
},
}, time.Now(), time.Now(), false)
}, time.Now(), time.Now(), false, false)
assert.NoError(t, err)
assert.Len(t, actual, 1)
@@ -1105,3 +1164,36 @@ func Test_ParseMetricDataQueries_statistics_and_query_type_validation_and_MatchE
assert.False(t, actual[0].MatchExact)
})
}
func Test_ParseMetricDataQueries_account_Id(t *testing.T) {
t.Run("account is set when cross account querying enabled", func(t *testing.T) {
actual, err := ParseMetricDataQueries(
[]backend.DataQuery{
{
JSON: []byte(`{"accountId":"some account id", "statistic":"Average"}`),
},
}, time.Now(), time.Now(), false, true)
assert.NoError(t, err)
require.Len(t, actual, 1)
require.NotNil(t, actual[0])
require.NotNil(t, actual[0].AccountId)
assert.Equal(t, "some account id", *actual[0].AccountId)
})
t.Run("account is not set when cross account querying disabled", func(t *testing.T) {
actual, err := ParseMetricDataQueries(
[]backend.DataQuery{
{
JSON: []byte(`{"accountId":"some account id", "statistic":"Average"}`),
},
}, time.Now(), time.Now(), false, false)
assert.NoError(t, err)
require.Len(t, actual, 1)
require.NotNil(t, actual[0])
assert.Nil(t, actual[0].AccountId)
})
}
func pointer[T any](arg T) *T { return &arg }

View File

@@ -0,0 +1,53 @@
package resources
import (
"fmt"
"net/url"
"strconv"
)
const defaultLogGroupLimit = int64(50)
type LogGroupsRequest struct {
ResourceRequest
Limit int64
LogGroupNamePrefix, LogGroupNamePattern *string
}
func (r LogGroupsRequest) IsTargetingAllAccounts() bool {
return *r.AccountId == "all"
}
func ParseLogGroupsRequest(parameters url.Values) (LogGroupsRequest, error) {
logGroupNamePrefix := setIfNotEmptyString(parameters.Get("logGroupNamePrefix"))
logGroupPattern := setIfNotEmptyString(parameters.Get("logGroupPattern"))
if logGroupNamePrefix != nil && logGroupPattern != nil {
return LogGroupsRequest{}, fmt.Errorf("cannot set both log group name prefix and pattern")
}
return LogGroupsRequest{
Limit: getLimit(parameters.Get("limit")),
ResourceRequest: ResourceRequest{
Region: parameters.Get("region"),
AccountId: setIfNotEmptyString(parameters.Get("accountId")),
},
LogGroupNamePrefix: logGroupNamePrefix,
LogGroupNamePattern: logGroupPattern,
}, nil
}
func setIfNotEmptyString(paramValue string) *string {
if paramValue == "" {
return nil
}
return &paramValue
}
func getLimit(limit string) int64 {
logGroupLimit := defaultLogGroupLimit
intLimit, err := strconv.ParseInt(limit, 10, 64)
if err == nil && intLimit > 0 {
logGroupLimit = intLimit
}
return logGroupLimit
}

View File

@@ -17,13 +17,13 @@ type MetricsRequest struct {
Namespace string
}
func GetMetricsRequest(parameters url.Values) (*MetricsRequest, error) {
func GetMetricsRequest(parameters url.Values) (MetricsRequest, error) {
resourceRequest, err := getResourceRequest(parameters)
if err != nil {
return nil, err
return MetricsRequest{}, err
}
return &MetricsRequest{
return MetricsRequest{
ResourceRequest: resourceRequest,
Namespace: parameters.Get("namespace"),
}, nil

View File

@@ -5,8 +5,15 @@ import (
"net/url"
)
const useLinkedAccountsId = "all"
type ResourceRequest struct {
Region string
Region string
AccountId *string
}
func (r *ResourceRequest) ShouldTargetAllAccounts() bool {
return r.AccountId != nil && *r.AccountId == useLinkedAccountsId
}
func getResourceRequest(parameters url.Values) (*ResourceRequest, error) {
@@ -14,9 +21,24 @@ func getResourceRequest(parameters url.Values) (*ResourceRequest, error) {
Region: parameters.Get("region"),
}
accountId := parameters.Get("accountId")
if accountId != "" {
request.AccountId = &accountId
}
if request.Region == "" {
return nil, fmt.Errorf("region is required")
}
return request, nil
}
type LogsRequest struct {
Limit int64
AccountId, LogGroupNamePrefix, LogGroupNamePattern *string
IsCrossAccountQueryingEnabled bool
}
func (r LogsRequest) IsTargetingAllAccounts() bool {
return *r.AccountId == useLinkedAccountsId
}

View File

@@ -1,11 +1,35 @@
package resources
import "github.com/aws/aws-sdk-go/service/cloudwatch"
type Dimension struct {
Name string
Value string
}
type ResourceResponse[T any] struct {
AccountId *string `json:"accountId,omitempty"`
Value T `json:"value"`
}
type MetricResponse struct {
*cloudwatch.Metric
AccountId *string `json:"accountId,omitempty"`
}
type Account struct {
Id string `json:"id"`
Arn string `json:"arn"`
Label string `json:"label"`
IsMonitoringAccount bool `json:"isMonitoringAccount"`
}
type Metric struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
}
type LogGroup struct {
Arn string `json:"arn"`
Name string `json:"name"`
}

View File

@@ -1,20 +1,5 @@
package models
import (
"net/url"
"github.com/grafana/grafana-plugin-sdk-go/backend"
)
type RequestContext struct {
MetricsClientProvider MetricsClientProvider
Settings CloudWatchSettings
}
type RequestContextFactoryFunc func(pluginCtx backend.PluginContext, region string) (reqCtx RequestContext, err error)
type RouteHandlerFunc func(pluginCtx backend.PluginContext, reqContextFactory RequestContextFactoryFunc, parameters url.Values) ([]byte, *HttpError)
type cloudWatchLink struct {
View string `json:"view"`
Stacked bool `json:"stacked"`
@@ -31,7 +16,8 @@ type metricExpression struct {
}
type metricStatMeta struct {
Stat string `json:"stat"`
Period int `json:"period"`
Label string `json:"label,omitempty"`
Stat string `json:"stat"`
Period int `json:"period"`
Label string `json:"label,omitempty"`
AccountId string `json:"accountId,omitempty"`
}

View File

@@ -19,10 +19,12 @@ func (e *cloudWatchExecutor) newResourceMux() *http.ServeMux {
mux.HandleFunc("/ec2-instance-attribute", handleResourceReq(e.handleGetEc2InstanceAttribute))
mux.HandleFunc("/resource-arns", handleResourceReq(e.handleGetResourceArns))
mux.HandleFunc("/log-groups", handleResourceReq(e.handleGetLogGroups))
mux.HandleFunc("/describe-log-groups", routes.ResourceRequestMiddleware(routes.LogGroupsHandler, logger, e.getRequestContext)) // supports CrossAccountQuerying
mux.HandleFunc("/all-log-groups", handleResourceReq(e.handleGetAllLogGroups))
mux.HandleFunc("/metrics", routes.ResourceRequestMiddleware(routes.MetricsHandler, logger, e.getRequestContext))
mux.HandleFunc("/dimension-values", routes.ResourceRequestMiddleware(routes.DimensionValuesHandler, logger, e.getRequestContext))
mux.HandleFunc("/dimension-keys", routes.ResourceRequestMiddleware(routes.DimensionKeysHandler, logger, e.getRequestContext))
mux.HandleFunc("/accounts", routes.ResourceRequestMiddleware(routes.AccountsHandler, logger, e.getRequestContext))
mux.HandleFunc("/namespaces", routes.ResourceRequestMiddleware(routes.NamespacesHandler, logger, e.getRequestContext))
return mux
}

View File

@@ -0,0 +1,55 @@
package routes
import (
"encoding/json"
"errors"
"fmt"
"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/services"
)
func AccountsHandler(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) {
region := parameters.Get("region")
if region == "" {
return nil, models.NewHttpError("error in AccountsHandler", http.StatusBadRequest, fmt.Errorf("region is required"))
}
service, err := newAccountsService(pluginCtx, reqCtxFactory, region)
if err != nil {
return nil, models.NewHttpError("error in AccountsHandler", http.StatusInternalServerError, err)
}
accounts, err := service.GetAccountsForCurrentUserOrRole()
if err != nil {
msg := "error getting accounts for current user or role"
switch {
case errors.Is(err, services.ErrAccessDeniedException):
return nil, models.NewHttpError(msg, http.StatusForbidden, err)
default:
return nil, models.NewHttpError(msg, http.StatusInternalServerError, err)
}
}
accountsResponse, err := json.Marshal(accounts)
if err != nil {
return nil, models.NewHttpError("error in AccountsHandler", http.StatusInternalServerError, err)
}
return accountsResponse, nil
}
// newAccountService is an account service factory.
//
// Stubbable by tests.
var newAccountsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.AccountsProvider, error) {
oamClient, err := reqCtxFactory(pluginCtx, region)
if err != nil {
return nil, err
}
return services.NewAccountsService(oamClient.OAMClientProvider), nil
}

View File

@@ -0,0 +1,96 @@
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/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/services"
"github.com/stretchr/testify/assert"
)
func Test_accounts_route(t *testing.T) {
origNewAccountsService := newAccountsService
t.Cleanup(func() {
newAccountsService = origNewAccountsService
})
t.Run("successfully returns array of accounts json", func(t *testing.T) {
mockAccountsService := mocks.AccountsServiceMock{}
mockAccountsService.On("GetAccountsForCurrentUserOrRole").Return([]resources.ResourceResponse[resources.Account]{{
Value: resources.Account{
Id: "123456789012",
Arn: "some arn",
Label: "some label",
IsMonitoringAccount: true,
},
}}, nil)
newAccountsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.AccountsProvider, error) {
return &mockAccountsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/accounts?region=us-east-1", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(AccountsHandler, logger, nil))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.JSONEq(t, `[{"value":{"id":"123456789012", "arn":"some arn", "isMonitoringAccount":true, "label":"some label"}}]`, rr.Body.String())
})
t.Run("rejects POST method", func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/accounts?region=us-east-1", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(AccountsHandler, logger, 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", "/accounts", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(AccountsHandler, logger, nil))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("returns 403 when accounts service returns ErrAccessDeniedException", func(t *testing.T) {
mockAccountsService := mocks.AccountsServiceMock{}
mockAccountsService.On("GetAccountsForCurrentUserOrRole").Return([]resources.ResourceResponse[resources.Account](nil),
fmt.Errorf("%w: %s", services.ErrAccessDeniedException, "some AWS message"))
newAccountsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.AccountsProvider, error) {
return &mockAccountsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/accounts?region=us-east-1", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(AccountsHandler, logger, nil))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.JSONEq(t,
`{"Message":"error getting accounts for current user or role: access denied. please check your IAM policy: some AWS message",
"Error":"access denied. please check your IAM policy: some AWS message","StatusCode":403}`, rr.Body.String())
})
t.Run("returns 500 when accounts service returns unknown error", func(t *testing.T) {
mockAccountsService := mocks.AccountsServiceMock{}
mockAccountsService.On("GetAccountsForCurrentUserOrRole").Return([]resources.ResourceResponse[resources.Account](nil), fmt.Errorf("some error"))
newAccountsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.AccountsProvider, error) {
return &mockAccountsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/accounts?region=us-east-1", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(AccountsHandler, logger, nil))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
assert.Equal(t, `{"Message":"error getting accounts for current user or role: some error","Error":"some error","StatusCode":500}`, rr.Body.String())
})
}

View File

@@ -22,7 +22,7 @@ func DimensionKeysHandler(pluginCtx backend.PluginContext, reqCtxFactory models.
return nil, models.NewHttpError("error in DimensionKeyHandler", http.StatusInternalServerError, err)
}
var response []string
var response []resources.ResourceResponse[string]
switch dimensionKeysRequest.Type() {
case resources.FilterDimensionKeysRequest:
response, err = service.GetDimensionKeysByDimensionFilter(dimensionKeysRequest)

View File

@@ -31,7 +31,7 @@ func Test_DimensionKeys_Route(t *testing.T) {
len(r.DimensionFilter) == 2 &&
assert.Contains(t, r.DimensionFilter, &resources.Dimension{Name: "NodeID", Value: "Shared"}) &&
assert.Contains(t, r.DimensionFilter, &resources.Dimension{Name: "stage", Value: "QueryCommit"})
})).Return([]string{}, nil).Once()
})).Return([]resources.ResourceResponse[string]{}, nil).Once()
newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) {
return &mockListMetricsService, nil
}
@@ -48,10 +48,10 @@ func Test_DimensionKeys_Route(t *testing.T) {
})
haveBeenCalled := false
usedNamespace := ""
services.GetHardCodedDimensionKeysByNamespace = func(namespace string) ([]string, error) {
services.GetHardCodedDimensionKeysByNamespace = func(namespace string) ([]resources.ResourceResponse[string], error) {
haveBeenCalled = true
usedNamespace = namespace
return []string{}, nil
return []resources.ResourceResponse[string]{}, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/dimension-keys?region=us-east-2&namespace=AWS/EC2&metricName=CPUUtilization", nil)
@@ -66,7 +66,7 @@ func Test_DimensionKeys_Route(t *testing.T) {
t.Run("return 500 if GetDimensionKeysByDimensionFilter returns an error", func(t *testing.T) {
mockListMetricsService := mocks.ListMetricsServiceMock{}
mockListMetricsService.On("GetDimensionKeysByDimensionFilter", mock.Anything).Return([]string{}, fmt.Errorf("some error"))
mockListMetricsService.On("GetDimensionKeysByDimensionFilter", mock.Anything).Return([]resources.ResourceResponse[string]{}, fmt.Errorf("some error"))
newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) {
return &mockListMetricsService, nil
}

View File

@@ -26,7 +26,7 @@ func Test_DimensionValues_Route(t *testing.T) {
len(r.DimensionFilter) == 2 &&
assert.Contains(t, r.DimensionFilter, &resources.Dimension{Name: "NodeID", Value: "Shared"}) &&
assert.Contains(t, r.DimensionFilter, &resources.Dimension{Name: "stage", Value: "QueryCommit"})
})).Return([]string{}, nil).Once()
})).Return([]resources.ResourceResponse[string]{}, nil).Once()
newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) {
return &mockListMetricsService, nil
}
@@ -38,7 +38,7 @@ func Test_DimensionValues_Route(t *testing.T) {
t.Run("returns 500 if GetDimensionValuesByDimensionFilter returns an error", func(t *testing.T) {
mockListMetricsService := mocks.ListMetricsServiceMock{}
mockListMetricsService.On("GetDimensionValuesByDimensionFilter", mock.Anything).Return([]string{}, fmt.Errorf("some error"))
mockListMetricsService.On("GetDimensionValuesByDimensionFilter", mock.Anything).Return([]resources.ResourceResponse[string]{}, fmt.Errorf("some error"))
newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) {
return &mockListMetricsService, nil
}

View File

@@ -0,0 +1,49 @@
package routes
import (
"encoding/json"
"net/http"
"net/url"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/services"
)
func LogGroupsHandler(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, parameters url.Values) ([]byte, *models.HttpError) {
request, err := resources.ParseLogGroupsRequest(parameters)
if err != nil {
return nil, models.NewHttpError("cannot set both log group name prefix and pattern", http.StatusBadRequest, err)
}
service, err := newLogGroupsService(pluginCtx, reqCtxFactory, request.Region)
if err != nil {
return nil, models.NewHttpError("newLogGroupsService error", http.StatusInternalServerError, err)
}
logGroups, err := service.GetLogGroups(request)
if err != nil {
return nil, models.NewHttpError("GetLogGroups error", http.StatusInternalServerError, err)
}
logGroupsResponse, err := json.Marshal(logGroups)
if err != nil {
return nil, models.NewHttpError("LogGroupsHandler json error", http.StatusInternalServerError, err)
}
return logGroupsResponse, nil
}
// newLogGroupsService is a describe log groups service factory.
//
// Stubbable by tests.
var newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) {
reqCtx, err := reqCtxFactory(pluginCtx, region)
if err != nil {
return nil, err
}
return services.NewLogGroupsService(reqCtx.LogsAPIProvider, reqCtx.Features.IsEnabled(featuremgmt.FlagCloudWatchCrossAccountQuerying)), nil
}

View File

@@ -0,0 +1,240 @@
package routes
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func Test_log_groups_route(t *testing.T) {
origLogGroupsService := newLogGroupsService
t.Cleanup(func() {
newLogGroupsService = origLogGroupsService
})
mockFeatures := mocks.MockFeatures{}
mockFeatures.On("IsEnabled", featuremgmt.FlagCloudWatchCrossAccountQuerying).Return(false)
reqCtxFunc := func(pluginCtx backend.PluginContext, region string) (reqCtx models.RequestContext, err error) {
return models.RequestContext{Features: &mockFeatures}, err
}
t.Run("successfully returns 1 log group with account id", func(t *testing.T) {
mockLogsService := mocks.LogsService{}
mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{{
Value: resources.LogGroup{
Arn: "some arn",
Name: "some name",
},
AccountId: utils.Pointer("111"),
}}, nil)
newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) {
return &mockLogsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/log-groups", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.JSONEq(t, `[{"value":{"name":"some name", "arn":"some arn"},"accountId":"111"}]`, rr.Body.String())
})
t.Run("successfully returns multiple log groups with account id", func(t *testing.T) {
mockLogsService := mocks.LogsService{}
mockLogsService.On("GetLogGroups", mock.Anything).Return(
[]resources.ResourceResponse[resources.LogGroup]{
{
Value: resources.LogGroup{
Arn: "arn 1",
Name: "name 1",
},
AccountId: utils.Pointer("111"),
}, {
Value: resources.LogGroup{
Arn: "arn 2",
Name: "name 2",
},
AccountId: utils.Pointer("222"),
},
}, nil)
newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) {
return &mockLogsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/log-groups", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.JSONEq(t, `[
{
"value":{
"name":"name 1",
"arn":"arn 1"
},
"accountId":"111"
},
{
"value":{
"name":"name 2",
"arn":"arn 2"
},
"accountId":"222"
}
]`, rr.Body.String())
})
t.Run("returns error when both logGroupPrefix and logGroup Pattern are provided", func(t *testing.T) {
mockLogsService := mocks.LogsService{}
mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil)
newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) {
return &mockLogsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/log-groups?logGroupNamePrefix=some-prefix&logGroupPattern=some-pattern", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.JSONEq(t, `{"Error":"cannot set both log group name prefix and pattern", "Message":"cannot set both log group name prefix and pattern: cannot set both log group name prefix and pattern", "StatusCode":400}`, rr.Body.String())
})
t.Run("passes default log group limit and nil for logGroupNamePrefix, accountId, and logGroupPattern", func(t *testing.T) {
mockLogsService := mocks.LogsService{}
mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil)
newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) {
return &mockLogsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/log-groups", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc))
handler.ServeHTTP(rr, req)
mockLogsService.AssertCalled(t, "GetLogGroups", resources.LogGroupsRequest{
Limit: 50,
ResourceRequest: resources.ResourceRequest{},
LogGroupNamePrefix: nil,
LogGroupNamePattern: nil,
})
})
t.Run("passes default log group limit and nil for logGroupNamePrefix when both are absent", func(t *testing.T) {
mockLogsService := mocks.LogsService{}
mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil)
newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) {
return &mockLogsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/log-groups", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc))
handler.ServeHTTP(rr, req)
mockLogsService.AssertCalled(t, "GetLogGroups", resources.LogGroupsRequest{
Limit: 50,
LogGroupNamePrefix: nil,
})
})
t.Run("passes log group limit from query parameter", func(t *testing.T) {
mockLogsService := mocks.LogsService{}
mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil)
newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) {
return &mockLogsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/log-groups?limit=2", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc))
handler.ServeHTTP(rr, req)
mockLogsService.AssertCalled(t, "GetLogGroups", resources.LogGroupsRequest{
Limit: 2,
})
})
t.Run("passes logGroupPrefix from query parameter", func(t *testing.T) {
mockLogsService := mocks.LogsService{}
mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil)
newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) {
return &mockLogsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/log-groups?logGroupNamePrefix=some-prefix", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc))
handler.ServeHTTP(rr, req)
mockLogsService.AssertCalled(t, "GetLogGroups", resources.LogGroupsRequest{
Limit: 50,
LogGroupNamePrefix: utils.Pointer("some-prefix"),
})
})
t.Run("passes logGroupPattern from query parameter", func(t *testing.T) {
mockLogsService := mocks.LogsService{}
mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil)
newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) {
return &mockLogsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/log-groups?logGroupPattern=some-pattern", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc))
handler.ServeHTTP(rr, req)
mockLogsService.AssertCalled(t, "GetLogGroups", resources.LogGroupsRequest{
Limit: 50,
LogGroupNamePattern: utils.Pointer("some-pattern"),
})
})
t.Run("passes logGroupPattern from query parameter", func(t *testing.T) {
mockLogsService := mocks.LogsService{}
mockLogsService.On("GetLogGroups", mock.Anything).Return([]resources.ResourceResponse[resources.LogGroup]{}, nil)
newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) {
return &mockLogsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/log-groups?accountId=some-account-id", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc))
handler.ServeHTTP(rr, req)
mockLogsService.AssertCalled(t, "GetLogGroups", resources.LogGroupsRequest{
Limit: 50,
ResourceRequest: resources.ResourceRequest{AccountId: utils.Pointer("some-account-id")},
})
})
t.Run("returns error if service returns error", func(t *testing.T) {
mockLogsService := mocks.LogsService{}
mockLogsService.On("GetLogGroups", mock.Anything).
Return([]resources.ResourceResponse[resources.LogGroup]{}, fmt.Errorf("some error"))
newLogGroupsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.LogGroupsProvider, error) {
return &mockLogsService, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/log-groups", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(LogGroupsHandler, logger, reqCtxFunc))
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
assert.JSONEq(t, `{"Error":"some error","Message":"GetLogGroups error: some error","StatusCode":500}`, rr.Body.String())
})
}

View File

@@ -22,20 +22,20 @@ func MetricsHandler(pluginCtx backend.PluginContext, reqCtxFactory models.Reques
return nil, models.NewHttpError("error in MetricsHandler", http.StatusInternalServerError, err)
}
var metrics []resources.Metric
var response []resources.ResourceResponse[resources.Metric]
switch metricsRequest.Type() {
case resources.AllMetricsRequestType:
metrics = services.GetAllHardCodedMetrics()
response = services.GetAllHardCodedMetrics()
case resources.MetricsByNamespaceRequestType:
metrics, err = services.GetHardCodedMetricsByNamespace(metricsRequest.Namespace)
response, err = services.GetHardCodedMetricsByNamespace(metricsRequest.Namespace)
case resources.CustomNamespaceRequestType:
metrics, err = service.GetMetricsByNamespace(metricsRequest.Namespace)
response, err = service.GetMetricsByNamespace(metricsRequest)
}
if err != nil {
return nil, models.NewHttpError("error in MetricsHandler", http.StatusInternalServerError, err)
}
metricsResponse, err := json.Marshal(metrics)
metricsResponse, err := json.Marshal(response)
if err != nil {
return nil, models.NewHttpError("error in MetricsHandler", http.StatusInternalServerError, err)
}

View File

@@ -1,27 +1,24 @@
package routes
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/services"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func Test_Metrics_Route(t *testing.T) {
t.Run("calls GetMetricsByNamespace when a CustomNamespaceRequestType is passed", func(t *testing.T) {
mockListMetricsService := mocks.ListMetricsServiceMock{}
mockListMetricsService.On("GetMetricsByNamespace", mock.Anything).Return([]resources.Metric{}, nil)
mockListMetricsService.On("GetMetricsByNamespace", mock.Anything).Return([]resources.ResourceResponse[resources.Metric]{}, nil)
newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) {
return &mockListMetricsService, nil
}
@@ -38,17 +35,14 @@ func Test_Metrics_Route(t *testing.T) {
services.GetAllHardCodedMetrics = origGetAllHardCodedMetrics
})
haveBeenCalled := false
services.GetAllHardCodedMetrics = func() []resources.Metric {
services.GetAllHardCodedMetrics = func() []resources.ResourceResponse[resources.Metric] {
haveBeenCalled = true
return []resources.Metric{}
return []resources.ResourceResponse[resources.Metric]{}
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/metrics?region=us-east-2", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(MetricsHandler, logger, nil))
handler.ServeHTTP(rr, req)
res := []resources.Metric{}
err := json.Unmarshal(rr.Body.Bytes(), &res)
require.Nil(t, err)
assert.True(t, haveBeenCalled)
})
@@ -59,25 +53,22 @@ func Test_Metrics_Route(t *testing.T) {
})
haveBeenCalled := false
usedNamespace := ""
services.GetHardCodedMetricsByNamespace = func(namespace string) ([]resources.Metric, error) {
services.GetHardCodedMetricsByNamespace = func(namespace string) ([]resources.ResourceResponse[resources.Metric], error) {
haveBeenCalled = true
usedNamespace = namespace
return []resources.Metric{}, nil
return []resources.ResourceResponse[resources.Metric]{}, nil
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/metrics?region=us-east-2&namespace=AWS/DMS", nil)
handler := http.HandlerFunc(ResourceRequestMiddleware(MetricsHandler, logger, nil))
handler.ServeHTTP(rr, req)
res := []resources.Metric{}
err := json.Unmarshal(rr.Body.Bytes(), &res)
require.Nil(t, err)
assert.True(t, haveBeenCalled)
assert.Equal(t, "AWS/DMS", usedNamespace)
})
t.Run("returns 500 if GetMetricsByNamespace returns an error", func(t *testing.T) {
mockListMetricsService := mocks.ListMetricsServiceMock{}
mockListMetricsService.On("GetMetricsByNamespace", mock.Anything).Return([]resources.Metric{}, fmt.Errorf("some error"))
mockListMetricsService.On("GetMetricsByNamespace", mock.Anything).Return([]resources.ResourceResponse[resources.Metric]{}, fmt.Errorf("some error"))
newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) {
return &mockListMetricsService, nil
}

View File

@@ -9,6 +9,7 @@ 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/resources"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/services"
)
@@ -18,14 +19,19 @@ func NamespacesHandler(pluginCtx backend.PluginContext, reqCtxFactory models.Req
return nil, models.NewHttpError("error in NamespacesHandler", http.StatusInternalServerError, err)
}
result := services.GetHardCodedNamespaces()
response := services.GetHardCodedNamespaces()
customNamespace := reqCtx.Settings.Namespace
if customNamespace != "" {
result = append(result, strings.Split(customNamespace, ",")...)
customNamespaces := strings.Split(customNamespace, ",")
for _, customNamespace := range customNamespaces {
response = append(response, resources.ResourceResponse[string]{Value: customNamespace})
}
}
sort.Strings(result)
sort.Slice(response, func(i, j int) bool {
return response[i].Value < response[j].Value
})
namespacesResponse, err := json.Marshal(result)
namespacesResponse, err := json.Marshal(response)
if err != nil {
return nil, models.NewHttpError("error in NamespacesHandler", http.StatusInternalServerError, err)
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/services"
)
@@ -28,9 +29,9 @@ func Test_Namespaces_Route(t *testing.T) {
services.GetHardCodedNamespaces = origGetHardCodedNamespaces
})
haveBeenCalled := false
services.GetHardCodedNamespaces = func() []string {
services.GetHardCodedNamespaces = func() []resources.ResourceResponse[string] {
haveBeenCalled = true
return []string{}
return []resources.ResourceResponse[string]{}
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/namespaces", nil)
@@ -44,15 +45,15 @@ func Test_Namespaces_Route(t *testing.T) {
t.Cleanup(func() {
services.GetHardCodedNamespaces = origGetHardCodedNamespaces
})
services.GetHardCodedNamespaces = func() []string {
return []string{"AWS/EC2", "AWS/ELB"}
services.GetHardCodedNamespaces = func() []resources.ResourceResponse[string] {
return []resources.ResourceResponse[string]{{Value: "AWS/EC2"}, {Value: "AWS/ELB"}}
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/namespaces", nil)
customNamespaces = "customNamespace1,customNamespace2"
handler := http.HandlerFunc(ResourceRequestMiddleware(NamespacesHandler, logger, factoryFunc))
handler.ServeHTTP(rr, req)
assert.JSONEq(t, `["AWS/EC2", "AWS/ELB", "customNamespace1", "customNamespace2"]`, rr.Body.String())
assert.JSONEq(t, `[{"value":"AWS/EC2"}, {"value":"AWS/ELB"}, {"value":"customNamespace1"}, {"value":"customNamespace2"}]`, rr.Body.String())
})
t.Run("sorts result", func(t *testing.T) {
@@ -60,14 +61,14 @@ func Test_Namespaces_Route(t *testing.T) {
t.Cleanup(func() {
services.GetHardCodedNamespaces = origGetHardCodedNamespaces
})
services.GetHardCodedNamespaces = func() []string {
return []string{"AWS/XYZ", "AWS/ELB"}
services.GetHardCodedNamespaces = func() []resources.ResourceResponse[string] {
return []resources.ResourceResponse[string]{{Value: "AWS/XYZ"}, {Value: "AWS/ELB"}}
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/namespaces", nil)
customNamespaces = "DCustomNamespace1,ACustomNamespace2"
handler := http.HandlerFunc(ResourceRequestMiddleware(NamespacesHandler, logger, factoryFunc))
handler.ServeHTTP(rr, req)
assert.JSONEq(t, `["ACustomNamespace2", "AWS/ELB", "AWS/XYZ", "DCustomNamespace1"]`, rr.Body.String())
assert.JSONEq(t, `[{"value":"ACustomNamespace2"}, {"value":"AWS/ELB"}, {"value":"AWS/XYZ"}, {"value":"DCustomNamespace1"}]`, rr.Body.String())
})
}

View File

@@ -0,0 +1,89 @@
package services
import (
"errors"
"fmt"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/service/oam"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
)
var ErrAccessDeniedException = errors.New("access denied. please check your IAM policy")
type AccountsService struct {
models.OAMClientProvider
}
func NewAccountsService(oamClient models.OAMClientProvider) models.AccountsProvider {
return &AccountsService{oamClient}
}
func (a *AccountsService) GetAccountsForCurrentUserOrRole() ([]resources.ResourceResponse[resources.Account], error) {
var nextToken *string
sinks := []*oam.ListSinksItem{}
for {
response, err := a.ListSinks(&oam.ListSinksInput{NextToken: nextToken})
if err != nil {
var aerr awserr.Error
if errors.As(err, &aerr) {
switch aerr.Code() {
// unlike many other services, OAM doesn't define this error code. however, it's returned in case calling role/user has insufficient permissions
case "AccessDeniedException":
return nil, fmt.Errorf("%w: %s", ErrAccessDeniedException, aerr.Message())
}
}
}
if err != nil {
return nil, fmt.Errorf("ListSinks error: %w", err)
}
sinks = append(sinks, response.Items...)
if response.NextToken == nil {
break
}
nextToken = response.NextToken
}
if len(sinks) == 0 {
return nil, nil
}
sinkIdentifier := sinks[0].Arn
response := []resources.Account{{
Id: getAccountId(*sinkIdentifier),
Label: *sinks[0].Name,
Arn: *sinkIdentifier,
IsMonitoringAccount: true,
}}
nextToken = nil
for {
links, err := a.ListAttachedLinks(&oam.ListAttachedLinksInput{
SinkIdentifier: sinkIdentifier,
NextToken: nextToken,
})
if err != nil {
return nil, fmt.Errorf("ListAttachedLinks error: %w", err)
}
for _, link := range links.Items {
arn := *link.LinkArn
response = append(response, resources.Account{
Id: getAccountId(arn),
Label: *link.Label,
Arn: arn,
IsMonitoringAccount: false,
})
}
if links.NextToken == nil {
break
}
nextToken = links.NextToken
}
return valuesToListMetricRespone(response), nil
}

View File

@@ -0,0 +1,165 @@
package services
import (
"fmt"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/service/oam"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestHandleGetAccounts(t *testing.T) {
t.Run("Should return an error in case of insufficient permissions from ListSinks", func(t *testing.T) {
fakeOAMClient := &mocks.FakeOAMClient{}
fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{}, awserr.New("AccessDeniedException",
"AWS message", nil))
accounts := NewAccountsService(fakeOAMClient)
resp, err := accounts.GetAccountsForCurrentUserOrRole()
assert.Error(t, err)
assert.Nil(t, resp)
assert.Equal(t, err.Error(), "access denied. please check your IAM policy: AWS message")
assert.ErrorIs(t, err, ErrAccessDeniedException)
})
t.Run("Should return an error in case of any error from ListSinks", func(t *testing.T) {
fakeOAMClient := &mocks.FakeOAMClient{}
fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{}, fmt.Errorf("some error"))
accounts := NewAccountsService(fakeOAMClient)
resp, err := accounts.GetAccountsForCurrentUserOrRole()
assert.Error(t, err)
assert.Nil(t, resp)
assert.Equal(t, err.Error(), "ListSinks error: some error")
})
t.Run("Should return empty array in case no monitoring account exists", func(t *testing.T) {
fakeOAMClient := &mocks.FakeOAMClient{}
fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{}, nil)
accounts := NewAccountsService(fakeOAMClient)
resp, err := accounts.GetAccountsForCurrentUserOrRole()
assert.NoError(t, err)
assert.Empty(t, resp)
})
t.Run("Should return one monitoring account (the first) even though ListSinks returns multiple sinks", func(t *testing.T) {
fakeOAMClient := &mocks.FakeOAMClient{}
fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{
Items: []*oam.ListSinksItem{
{Name: aws.String("Account 1"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1")},
{Name: aws.String("Account 2"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group2")},
},
NextToken: new(string),
}, nil).Once()
fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{
Items: []*oam.ListSinksItem{
{Name: aws.String("Account 3"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group3")},
},
NextToken: nil,
}, nil)
fakeOAMClient.On("ListAttachedLinks", mock.Anything).Return(&oam.ListAttachedLinksOutput{}, nil)
accounts := NewAccountsService(fakeOAMClient)
resp, err := accounts.GetAccountsForCurrentUserOrRole()
assert.NoError(t, err)
fakeOAMClient.AssertNumberOfCalls(t, "ListSinks", 2)
require.Len(t, resp, 1)
assert.True(t, resp[0].Value.IsMonitoringAccount)
assert.Equal(t, "Account 1", resp[0].Value.Label)
assert.Equal(t, "arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1", resp[0].Value.Arn)
})
t.Run("Should merge the first sink with attached links", func(t *testing.T) {
fakeOAMClient := &mocks.FakeOAMClient{}
fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{
Items: []*oam.ListSinksItem{
{Name: aws.String("Account 1"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1")},
{Name: aws.String("Account 2"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group2")},
},
NextToken: new(string),
}, nil).Once()
fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{
Items: []*oam.ListSinksItem{
{Name: aws.String("Account 3"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group3")},
},
NextToken: nil,
}, nil)
fakeOAMClient.On("ListAttachedLinks", mock.Anything).Return(&oam.ListAttachedLinksOutput{
Items: []*oam.ListAttachedLinksItem{
{Label: aws.String("Account 10"), LinkArn: aws.String("arn:aws:logs:us-east-1:123456789013:log-group:my-log-group10")},
{Label: aws.String("Account 11"), LinkArn: aws.String("arn:aws:logs:us-east-1:123456789014:log-group:my-log-group11")},
},
NextToken: new(string),
}, nil).Once()
fakeOAMClient.On("ListAttachedLinks", mock.Anything).Return(&oam.ListAttachedLinksOutput{
Items: []*oam.ListAttachedLinksItem{
{Label: aws.String("Account 12"), LinkArn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group12")},
},
NextToken: nil,
}, nil)
accounts := NewAccountsService(fakeOAMClient)
resp, err := accounts.GetAccountsForCurrentUserOrRole()
assert.NoError(t, err)
fakeOAMClient.AssertNumberOfCalls(t, "ListSinks", 2)
fakeOAMClient.AssertNumberOfCalls(t, "ListAttachedLinks", 2)
expectedAccounts := []resources.ResourceResponse[resources.Account]{
{Value: resources.Account{Id: "123456789012", Label: "Account 1", Arn: "arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1", IsMonitoringAccount: true}},
{Value: resources.Account{Id: "123456789013", Label: "Account 10", Arn: "arn:aws:logs:us-east-1:123456789013:log-group:my-log-group10", IsMonitoringAccount: false}},
{Value: resources.Account{Id: "123456789014", Label: "Account 11", Arn: "arn:aws:logs:us-east-1:123456789014:log-group:my-log-group11", IsMonitoringAccount: false}},
{Value: resources.Account{Id: "123456789012", Label: "Account 12", Arn: "arn:aws:logs:us-east-1:123456789012:log-group:my-log-group12", IsMonitoringAccount: false}},
}
assert.Equal(t, expectedAccounts, resp)
})
t.Run("Should call ListAttachedLinks with arn of first sink", func(t *testing.T) {
fakeOAMClient := &mocks.FakeOAMClient{}
fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{
Items: []*oam.ListSinksItem{
{Name: aws.String("Account 1"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1")},
},
NextToken: new(string),
}, nil).Once()
fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{
Items: []*oam.ListSinksItem{
{Name: aws.String("Account 3"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group3")},
},
NextToken: nil,
}, nil).Once()
fakeOAMClient.On("ListAttachedLinks", mock.Anything).Return(&oam.ListAttachedLinksOutput{}, nil)
accounts := NewAccountsService(fakeOAMClient)
_, _ = accounts.GetAccountsForCurrentUserOrRole()
fakeOAMClient.AssertCalled(t, "ListAttachedLinks", &oam.ListAttachedLinksInput{
SinkIdentifier: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1"),
})
})
t.Run("Should return an error in case of any error from ListAttachedLinks", func(t *testing.T) {
fakeOAMClient := &mocks.FakeOAMClient{}
fakeOAMClient.On("ListSinks", mock.Anything).Return(&oam.ListSinksOutput{
Items: []*oam.ListSinksItem{{Name: aws.String("Account 1"), Arn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:my-log-group1")}},
}, nil)
fakeOAMClient.On("ListAttachedLinks", mock.Anything).Return(&oam.ListAttachedLinksOutput{}, fmt.Errorf("some error")).Once()
accounts := NewAccountsService(fakeOAMClient)
resp, err := accounts.GetAccountsForCurrentUserOrRole()
assert.Error(t, err)
assert.Nil(t, resp)
assert.Equal(t, err.Error(), "ListAttachedLinks error: some error")
})
}

View File

@@ -7,16 +7,16 @@ import (
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
)
var GetHardCodedDimensionKeysByNamespace = func(namespace string) ([]string, error) {
var dimensionKeys []string
var GetHardCodedDimensionKeysByNamespace = func(namespace string) ([]resources.ResourceResponse[string], error) {
var response []string
exists := false
if dimensionKeys, exists = constants.NamespaceDimensionKeysMap[namespace]; !exists {
if response, exists = constants.NamespaceDimensionKeysMap[namespace]; !exists {
return nil, fmt.Errorf("unable to find dimensions for namespace '%q'", namespace)
}
return dimensionKeys, nil
return valuesToListMetricRespone(response), nil
}
var GetHardCodedMetricsByNamespace = func(namespace string) ([]resources.Metric, error) {
var GetHardCodedMetricsByNamespace = func(namespace string) ([]resources.ResourceResponse[resources.Metric], error) {
response := []resources.Metric{}
exists := false
var metrics []string
@@ -28,10 +28,10 @@ var GetHardCodedMetricsByNamespace = func(namespace string) ([]resources.Metric,
response = append(response, resources.Metric{Namespace: namespace, Name: metric})
}
return response, nil
return valuesToListMetricRespone(response), nil
}
var GetAllHardCodedMetrics = func() []resources.Metric {
var GetAllHardCodedMetrics = func() []resources.ResourceResponse[resources.Metric] {
response := []resources.Metric{}
for namespace, metrics := range constants.NamespaceMetricsMap {
for _, metric := range metrics {
@@ -39,14 +39,14 @@ var GetAllHardCodedMetrics = func() []resources.Metric {
}
}
return response
return valuesToListMetricRespone(response)
}
var GetHardCodedNamespaces = func() []string {
var namespaces []string
var GetHardCodedNamespaces = func() []resources.ResourceResponse[string] {
response := []string{}
for key := range constants.NamespaceMetricsMap {
namespaces = append(namespaces, key)
response = append(response, key)
}
return namespaces
return valuesToListMetricRespone(response)
}

View File

@@ -19,7 +19,7 @@ func TestHardcodedMetrics_GetHardCodedDimensionKeysByNamespace(t *testing.T) {
t.Run("Should return keys if namespace exist", func(t *testing.T) {
resp, err := GetHardCodedDimensionKeysByNamespace("AWS/EC2")
require.NoError(t, err)
assert.Equal(t, []string{"AutoScalingGroupName", "ImageId", "InstanceId", "InstanceType"}, resp)
assert.Equal(t, []resources.ResourceResponse[string]{{Value: "AutoScalingGroupName"}, {Value: "ImageId"}, {Value: "InstanceId"}, {Value: "InstanceType"}}, resp)
})
}
@@ -34,6 +34,6 @@ func TestHardcodedMetrics_GetHardCodedMetricsByNamespace(t *testing.T) {
t.Run("Should return metrics if namespace exist", func(t *testing.T) {
resp, err := GetHardCodedMetricsByNamespace("AWS/IoTAnalytics")
require.NoError(t, err)
assert.Equal(t, []resources.Metric{{Name: "ActionExecution", Namespace: "AWS/IoTAnalytics"}, {Name: "ActivityExecutionError", Namespace: "AWS/IoTAnalytics"}, {Name: "IncomingMessages", Namespace: "AWS/IoTAnalytics"}}, resp)
assert.Equal(t, []resources.ResourceResponse[resources.Metric]{{Value: resources.Metric{Name: "ActionExecution", Namespace: "AWS/IoTAnalytics"}}, {Value: resources.Metric{Name: "ActivityExecutionError", Namespace: "AWS/IoTAnalytics"}}, {Value: resources.Metric{Name: "IncomingMessages", Namespace: "AWS/IoTAnalytics"}}}, resp)
})
}

View File

@@ -18,7 +18,7 @@ func NewListMetricsService(metricsClient models.MetricsClientProvider) models.Li
return &ListMetricsService{metricsClient}
}
func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r resources.DimensionKeysRequest) ([]string, error) {
func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r resources.DimensionKeysRequest) ([]resources.ResourceResponse[string], error) {
input := &cloudwatch.ListMetricsInput{}
if r.Namespace != "" {
input.Namespace = aws.String(r.Namespace)
@@ -27,13 +27,14 @@ func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r resources.Dimen
input.MetricName = aws.String(r.MetricName)
}
setDimensionFilter(input, r.DimensionFilter)
setAccount(input, r.ResourceRequest)
metrics, err := l.ListMetricsWithPageLimit(input)
if err != nil {
return nil, fmt.Errorf("%v: %w", "unable to call AWS API", err)
}
var dimensionKeys []string
response := []resources.ResourceResponse[string]{}
// remove duplicates
dupCheck := make(map[string]struct{})
for _, metric := range metrics {
@@ -56,26 +57,27 @@ func (l *ListMetricsService) GetDimensionKeysByDimensionFilter(r resources.Dimen
}
dupCheck[*dim.Name] = struct{}{}
dimensionKeys = append(dimensionKeys, *dim.Name)
response = append(response, resources.ResourceResponse[string]{AccountId: metric.AccountId, Value: *dim.Name})
}
}
return dimensionKeys, nil
return response, nil
}
func (l *ListMetricsService) GetDimensionValuesByDimensionFilter(r resources.DimensionValuesRequest) ([]string, error) {
func (l *ListMetricsService) GetDimensionValuesByDimensionFilter(r resources.DimensionValuesRequest) ([]resources.ResourceResponse[string], error) {
input := &cloudwatch.ListMetricsInput{
Namespace: aws.String(r.Namespace),
MetricName: aws.String(r.MetricName),
}
setDimensionFilter(input, r.DimensionFilter)
setAccount(input, r.ResourceRequest)
metrics, err := l.ListMetricsWithPageLimit(input)
if err != nil {
return nil, fmt.Errorf("%v: %w", "unable to call AWS API", err)
}
var dimensionValues []string
response := []resources.ResourceResponse[string]{}
dupCheck := make(map[string]bool)
for _, metric := range metrics {
for _, dim := range metric.Dimensions {
@@ -85,51 +87,33 @@ func (l *ListMetricsService) GetDimensionValuesByDimensionFilter(r resources.Dim
}
dupCheck[*dim.Value] = true
dimensionValues = append(dimensionValues, *dim.Value)
response = append(response, resources.ResourceResponse[string]{AccountId: metric.AccountId, Value: *dim.Value})
}
}
}
sort.Strings(dimensionValues)
return dimensionValues, nil
sort.Slice(response, func(i, j int) bool {
return response[i].Value < response[j].Value
})
return response, nil
}
func (l *ListMetricsService) GetDimensionKeysByNamespace(namespace string) ([]string, error) {
metrics, err := l.ListMetricsWithPageLimit(&cloudwatch.ListMetricsInput{Namespace: aws.String(namespace)})
if err != nil {
return []string{}, err
}
var dimensionKeys []string
dupCheck := make(map[string]struct{})
for _, metric := range metrics {
for _, dim := range metric.Dimensions {
if _, exists := dupCheck[*dim.Name]; exists {
continue
}
dupCheck[*dim.Name] = struct{}{}
dimensionKeys = append(dimensionKeys, *dim.Name)
}
}
return dimensionKeys, nil
}
func (l *ListMetricsService) GetMetricsByNamespace(namespace string) ([]resources.Metric, error) {
metrics, err := l.ListMetricsWithPageLimit(&cloudwatch.ListMetricsInput{Namespace: aws.String(namespace)})
func (l *ListMetricsService) GetMetricsByNamespace(r resources.MetricsRequest) ([]resources.ResourceResponse[resources.Metric], error) {
input := &cloudwatch.ListMetricsInput{Namespace: aws.String(r.Namespace)}
setAccount(input, r.ResourceRequest)
metrics, err := l.ListMetricsWithPageLimit(input)
if err != nil {
return nil, err
}
response := []resources.Metric{}
response := []resources.ResourceResponse[resources.Metric]{}
dupCheck := make(map[string]struct{})
for _, metric := range metrics {
if _, exists := dupCheck[*metric.MetricName]; exists {
continue
}
dupCheck[*metric.MetricName] = struct{}{}
response = append(response, resources.Metric{Name: *metric.MetricName, Namespace: *metric.Namespace})
response = append(response, resources.ResourceResponse[resources.Metric]{AccountId: metric.AccountId, Value: resources.Metric{Name: *metric.MetricName, Namespace: *metric.Namespace}})
}
return response, nil
@@ -146,3 +130,12 @@ func setDimensionFilter(input *cloudwatch.ListMetricsInput, dimensionFilter []*r
input.Dimensions = append(input.Dimensions, df)
}
}
func setAccount(input *cloudwatch.ListMetricsInput, r *resources.ResourceRequest) {
if r != nil && r.AccountId != nil {
input.IncludeLinkedAccounts = aws.Bool(true)
if !r.ShouldTargetAllAccounts() {
input.OwningAccount = r.AccountId
}
}
}

View File

@@ -7,40 +7,55 @@ import (
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
var metricResponse = []*cloudwatch.Metric{
const useLinkedAccountsId = "all"
var metricResponse = []resources.MetricResponse{
{
MetricName: aws.String("CPUUtilization"),
Namespace: aws.String("AWS/EC2"),
Dimensions: []*cloudwatch.Dimension{
{Name: aws.String("InstanceId"), Value: aws.String("i-1234567890abcdef0")},
{Name: aws.String("InstanceType"), Value: aws.String("t2.micro")},
Metric: &cloudwatch.Metric{
MetricName: aws.String("CPUUtilization"),
Namespace: aws.String("AWS/EC2"),
Dimensions: []*cloudwatch.Dimension{
{Name: aws.String("InstanceId"), Value: aws.String("i-1234567890abcdef0")},
{Name: aws.String("InstanceType"), Value: aws.String("t2.micro")},
},
},
},
{
MetricName: aws.String("CPUUtilization"),
Namespace: aws.String("AWS/EC2"),
Dimensions: []*cloudwatch.Dimension{
{Name: aws.String("InstanceId"), Value: aws.String("i-5234567890abcdef0")},
{Name: aws.String("InstanceType"), Value: aws.String("t2.micro")},
{Name: aws.String("AutoScalingGroupName"), Value: aws.String("my-asg")},
Metric: &cloudwatch.Metric{
MetricName: aws.String("CPUUtilization"),
Namespace: aws.String("AWS/EC2"),
Dimensions: []*cloudwatch.Dimension{
{Name: aws.String("InstanceId"), Value: aws.String("i-5234567890abcdef0")},
{Name: aws.String("InstanceType"), Value: aws.String("t2.micro")},
{Name: aws.String("AutoScalingGroupName"), Value: aws.String("my-asg")},
},
},
},
{
MetricName: aws.String("CPUUtilization"),
Namespace: aws.String("AWS/EC2"),
Dimensions: []*cloudwatch.Dimension{
{Name: aws.String("InstanceId"), Value: aws.String("i-64234567890abcdef0")},
{Name: aws.String("InstanceType"), Value: aws.String("t3.micro")},
{Name: aws.String("AutoScalingGroupName"), Value: aws.String("my-asg2")},
Metric: &cloudwatch.Metric{
MetricName: aws.String("CPUUtilization"),
Namespace: aws.String("AWS/EC2"),
Dimensions: []*cloudwatch.Dimension{
{Name: aws.String("InstanceId"), Value: aws.String("i-64234567890abcdef0")},
{Name: aws.String("InstanceType"), Value: aws.String("t3.micro")},
{Name: aws.String("AutoScalingGroupName"), Value: aws.String("my-asg2")},
},
},
},
}
type validateInputTestCase[T resources.DimensionKeysRequest | resources.DimensionValuesRequest] struct {
name string
input T
listMetricsWithPageLimitInput *cloudwatch.ListMetricsInput
}
func TestListMetricsService_GetDimensionKeysByDimensionFilter(t *testing.T) {
t.Run("Should filter out duplicates and keys matching dimension filter keys", func(t *testing.T) {
fakeMetricsClient := &mocks.FakeMetricsClient{}
@@ -51,27 +66,68 @@ func TestListMetricsService_GetDimensionKeysByDimensionFilter(t *testing.T) {
ResourceRequest: &resources.ResourceRequest{Region: "us-east-1"},
Namespace: "AWS/EC2",
MetricName: "CPUUtilization",
DimensionFilter: []*resources.Dimension{
{Name: "InstanceId", Value: ""},
},
DimensionFilter: []*resources.Dimension{{Name: "InstanceId", Value: ""}},
})
require.NoError(t, err)
assert.Equal(t, []string{"InstanceType", "AutoScalingGroupName"}, resp)
assert.Equal(t, []resources.ResourceResponse[string]{{Value: "InstanceType"}, {Value: "AutoScalingGroupName"}}, resp)
})
}
func TestListMetricsService_GetDimensionKeysByNamespace(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)
testCases := []validateInputTestCase[resources.DimensionKeysRequest]{
{
name: "Should set account correctly on list metric input if it cross account is defined on the request",
input: resources.DimensionKeysRequest{
ResourceRequest: &resources.ResourceRequest{Region: "us-east-1", AccountId: utils.Pointer(useLinkedAccountsId)},
Namespace: "AWS/EC2",
MetricName: "CPUUtilization",
DimensionFilter: []*resources.Dimension{{Name: "InstanceId", Value: ""}},
},
listMetricsWithPageLimitInput: &cloudwatch.ListMetricsInput{
MetricName: aws.String("CPUUtilization"),
Namespace: aws.String("AWS/EC2"),
Dimensions: []*cloudwatch.DimensionFilter{{Name: aws.String("InstanceId")}},
IncludeLinkedAccounts: aws.Bool(true),
},
},
{
name: "Should set account correctly on list metric input if single account is defined on the request",
input: resources.DimensionKeysRequest{
ResourceRequest: &resources.ResourceRequest{Region: "us-east-1", AccountId: utils.Pointer("1234567890")},
Namespace: "AWS/EC2",
MetricName: "CPUUtilization",
DimensionFilter: []*resources.Dimension{{Name: "InstanceId", Value: ""}},
},
listMetricsWithPageLimitInput: &cloudwatch.ListMetricsInput{
MetricName: aws.String("CPUUtilization"),
Namespace: aws.String("AWS/EC2"),
Dimensions: []*cloudwatch.DimensionFilter{{Name: aws.String("InstanceId")}},
IncludeLinkedAccounts: aws.Bool(true),
OwningAccount: aws.String("1234567890"),
},
},
{
name: "Should not set namespace and metricName on list metric input if empty strings are set for these in the request",
input: resources.DimensionKeysRequest{
ResourceRequest: &resources.ResourceRequest{Region: "us-east-1"},
Namespace: "",
MetricName: "",
DimensionFilter: []*resources.Dimension{{Name: "InstanceId", Value: ""}},
},
listMetricsWithPageLimitInput: &cloudwatch.ListMetricsInput{Dimensions: []*cloudwatch.DimensionFilter{{Name: aws.String("InstanceId")}}},
},
}
resp, err := listMetricsService.GetDimensionKeysByNamespace("AWS/EC2")
require.NoError(t, err)
assert.Equal(t, []string{"InstanceId", "InstanceType", "AutoScalingGroupName"}, resp)
})
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fakeMetricsClient := &mocks.FakeMetricsClient{}
fakeMetricsClient.On("ListMetricsWithPageLimit", mock.Anything).Return(metricResponse, nil)
listMetricsService := NewListMetricsService(fakeMetricsClient)
res, err := listMetricsService.GetDimensionKeysByDimensionFilter(tc.input)
require.NoError(t, err)
require.NotEmpty(t, res)
fakeMetricsClient.AssertCalled(t, "ListMetricsWithPageLimit", tc.listMetricsWithPageLimitInput)
})
}
}
func TestListMetricsService_GetDimensionValuesByDimensionFilter(t *testing.T) {
@@ -91,6 +147,52 @@ func TestListMetricsService_GetDimensionValuesByDimensionFilter(t *testing.T) {
})
require.NoError(t, err)
assert.Equal(t, []string{"i-1234567890abcdef0", "i-5234567890abcdef0", "i-64234567890abcdef0"}, resp)
assert.Equal(t, []resources.ResourceResponse[string]{{Value: "i-1234567890abcdef0"}, {Value: "i-5234567890abcdef0"}, {Value: "i-64234567890abcdef0"}}, resp)
})
testCases := []validateInputTestCase[resources.DimensionValuesRequest]{
{
name: "Should set account correctly on list metric input if it cross account is defined on the request",
input: resources.DimensionValuesRequest{
ResourceRequest: &resources.ResourceRequest{Region: "us-east-1", AccountId: utils.Pointer(useLinkedAccountsId)},
Namespace: "AWS/EC2",
MetricName: "CPUUtilization",
DimensionFilter: []*resources.Dimension{{Name: "InstanceId", Value: ""}},
},
listMetricsWithPageLimitInput: &cloudwatch.ListMetricsInput{
MetricName: aws.String("CPUUtilization"),
Namespace: aws.String("AWS/EC2"),
Dimensions: []*cloudwatch.DimensionFilter{{Name: aws.String("InstanceId")}},
IncludeLinkedAccounts: aws.Bool(true),
},
},
{
name: "Should set account correctly on list metric input if single account is defined on the request",
input: resources.DimensionValuesRequest{
ResourceRequest: &resources.ResourceRequest{Region: "us-east-1", AccountId: utils.Pointer("1234567890")},
Namespace: "AWS/EC2",
MetricName: "CPUUtilization",
DimensionFilter: []*resources.Dimension{{Name: "InstanceId", Value: ""}},
},
listMetricsWithPageLimitInput: &cloudwatch.ListMetricsInput{
MetricName: aws.String("CPUUtilization"),
Namespace: aws.String("AWS/EC2"),
Dimensions: []*cloudwatch.DimensionFilter{{Name: aws.String("InstanceId")}},
IncludeLinkedAccounts: aws.Bool(true),
OwningAccount: aws.String("1234567890"),
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fakeMetricsClient := &mocks.FakeMetricsClient{}
fakeMetricsClient.On("ListMetricsWithPageLimit", mock.Anything).Return(metricResponse, nil)
listMetricsService := NewListMetricsService(fakeMetricsClient)
res, err := listMetricsService.GetDimensionValuesByDimensionFilter(tc.input)
require.NoError(t, err)
require.Empty(t, res)
fakeMetricsClient.AssertCalled(t, "ListMetricsWithPageLimit", tc.listMetricsWithPageLimitInput)
})
}
}

View File

@@ -0,0 +1,53 @@
package services
import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/cloudwatchlogs"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils"
)
type LogGroupsService struct {
logGroupsAPI models.CloudWatchLogsAPIProvider
isCrossAccountEnabled bool
}
func NewLogGroupsService(logsClient models.CloudWatchLogsAPIProvider, isCrossAccountEnabled bool) models.LogGroupsProvider {
return &LogGroupsService{logGroupsAPI: logsClient, isCrossAccountEnabled: isCrossAccountEnabled}
}
func (s *LogGroupsService) GetLogGroups(req resources.LogGroupsRequest) ([]resources.ResourceResponse[resources.LogGroup], error) {
input := &cloudwatchlogs.DescribeLogGroupsInput{
Limit: aws.Int64(req.Limit),
LogGroupNamePrefix: req.LogGroupNamePrefix,
}
if s.isCrossAccountEnabled && req.AccountId != nil {
input.IncludeLinkedAccounts = aws.Bool(true)
if req.LogGroupNamePattern != nil {
input.LogGroupNamePrefix = req.LogGroupNamePattern
}
if !req.IsTargetingAllAccounts() {
// TODO: accept more than one account id in search
input.AccountIdentifiers = []*string{req.AccountId}
}
}
response, err := s.logGroupsAPI.DescribeLogGroups(input)
if err != nil || response == nil {
return nil, err
}
var result []resources.ResourceResponse[resources.LogGroup]
for _, logGroup := range response.LogGroups {
result = append(result, resources.ResourceResponse[resources.LogGroup]{
Value: resources.LogGroup{
Arn: *logGroup.Arn,
Name: *logGroup.LogGroupName,
},
AccountId: utils.Pointer(getAccountId(*logGroup.Arn)),
})
}
return result, nil
}

View File

@@ -0,0 +1,200 @@
package services
import (
"fmt"
"testing"
"github.com/aws/aws-sdk-go/service/cloudwatchlogs"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func Test_GetLogGroups(t *testing.T) {
t.Run("Should map log groups response", func(t *testing.T) {
mockLogsAPI := &mocks.LogsAPI{}
mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(
&cloudwatchlogs.DescribeLogGroupsOutput{
LogGroups: []*cloudwatchlogs.LogGroup{
{Arn: utils.Pointer("arn:aws:logs:us-east-1:111:log-group:group_a"), LogGroupName: utils.Pointer("group_a")},
{Arn: utils.Pointer("arn:aws:logs:us-east-1:222:log-group:group_b"), LogGroupName: utils.Pointer("group_b")},
{Arn: utils.Pointer("arn:aws:logs:us-east-1:333:log-group:group_c"), LogGroupName: utils.Pointer("group_c")},
},
}, nil)
service := NewLogGroupsService(mockLogsAPI, false)
resp, err := service.GetLogGroups(resources.LogGroupsRequest{})
assert.NoError(t, err)
assert.Equal(t, []resources.ResourceResponse[resources.LogGroup]{
{
AccountId: utils.Pointer("111"),
Value: resources.LogGroup{Arn: "arn:aws:logs:us-east-1:111:log-group:group_a", Name: "group_a"},
},
{
AccountId: utils.Pointer("222"),
Value: resources.LogGroup{Arn: "arn:aws:logs:us-east-1:222:log-group:group_b", Name: "group_b"},
},
{
AccountId: utils.Pointer("333"),
Value: resources.LogGroup{Arn: "arn:aws:logs:us-east-1:333:log-group:group_c", Name: "group_c"},
},
}, resp)
})
t.Run("Should only use LogGroupNamePrefix even if LogGroupNamePattern passed in resource call", func(t *testing.T) {
// TODO: use LogGroupNamePattern when we have accounted for its behavior, still a little unexpected at the moment
mockLogsAPI := &mocks.LogsAPI{}
mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil)
service := NewLogGroupsService(mockLogsAPI, false)
_, err := service.GetLogGroups(resources.LogGroupsRequest{
Limit: 0,
LogGroupNamePrefix: utils.Pointer("test"),
})
assert.NoError(t, err)
mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{
Limit: utils.Pointer(int64(0)),
LogGroupNamePrefix: utils.Pointer("test"),
})
})
t.Run("Should call api without LogGroupNamePrefix nor LogGroupNamePattern if not passed in resource call", func(t *testing.T) {
mockLogsAPI := &mocks.LogsAPI{}
mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil)
service := NewLogGroupsService(mockLogsAPI, false)
_, err := service.GetLogGroups(resources.LogGroupsRequest{})
assert.NoError(t, err)
mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{
Limit: utils.Pointer(int64(0)),
})
})
t.Run("Should return an error when API returns error", func(t *testing.T) {
mockLogsAPI := &mocks.LogsAPI{}
mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{},
fmt.Errorf("some error"))
service := NewLogGroupsService(mockLogsAPI, false)
_, err := service.GetLogGroups(resources.LogGroupsRequest{})
assert.Error(t, err)
assert.Equal(t, "some error", err.Error())
})
}
func Test_GetLogGroups_crossAccountQuerying(t *testing.T) {
t.Run("Should not includeLinkedAccounts or accountId if isCrossAccountEnabled is set to false", func(t *testing.T) {
mockLogsAPI := &mocks.LogsAPI{}
mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil)
service := NewLogGroupsService(mockLogsAPI, false)
_, err := service.GetLogGroups(resources.LogGroupsRequest{
ResourceRequest: resources.ResourceRequest{AccountId: utils.Pointer("accountId")},
LogGroupNamePrefix: utils.Pointer("prefix"),
})
assert.NoError(t, err)
mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{
Limit: utils.Pointer(int64(0)),
LogGroupNamePrefix: utils.Pointer("prefix"),
})
})
t.Run("Should replace LogGroupNamePrefix if LogGroupNamePattern passed in resource call", func(t *testing.T) {
mockLogsAPI := &mocks.LogsAPI{}
mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil)
service := NewLogGroupsService(mockLogsAPI, true)
_, err := service.GetLogGroups(resources.LogGroupsRequest{
ResourceRequest: resources.ResourceRequest{AccountId: utils.Pointer("accountId")},
LogGroupNamePrefix: utils.Pointer("prefix"),
LogGroupNamePattern: utils.Pointer("pattern"),
})
assert.NoError(t, err)
mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{
AccountIdentifiers: []*string{utils.Pointer("accountId")},
Limit: utils.Pointer(int64(0)),
LogGroupNamePrefix: utils.Pointer("pattern"),
IncludeLinkedAccounts: utils.Pointer(true),
})
})
t.Run("Should includeLinkedAccounts,and accountId if isCrossAccountEnabled is set to true", func(t *testing.T) {
mockLogsAPI := &mocks.LogsAPI{}
mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil)
service := NewLogGroupsService(mockLogsAPI, true)
_, err := service.GetLogGroups(resources.LogGroupsRequest{
ResourceRequest: resources.ResourceRequest{AccountId: utils.Pointer("accountId")},
})
assert.NoError(t, err)
mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{
Limit: utils.Pointer(int64(0)),
IncludeLinkedAccounts: utils.Pointer(true),
AccountIdentifiers: []*string{utils.Pointer("accountId")},
})
})
t.Run("Should should not override prefix is there is no logGroupNamePattern", func(t *testing.T) {
mockLogsAPI := &mocks.LogsAPI{}
mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil)
service := NewLogGroupsService(mockLogsAPI, true)
_, err := service.GetLogGroups(resources.LogGroupsRequest{
ResourceRequest: resources.ResourceRequest{AccountId: utils.Pointer("accountId")},
LogGroupNamePrefix: utils.Pointer("prefix"),
})
assert.NoError(t, err)
mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{
AccountIdentifiers: []*string{utils.Pointer("accountId")},
Limit: utils.Pointer(int64(0)),
LogGroupNamePrefix: utils.Pointer("prefix"),
IncludeLinkedAccounts: utils.Pointer(true),
})
})
t.Run("Should not includeLinkedAccounts, or accountId if accountId is nil", func(t *testing.T) {
mockLogsAPI := &mocks.LogsAPI{}
mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil)
service := NewLogGroupsService(mockLogsAPI, true)
_, err := service.GetLogGroups(resources.LogGroupsRequest{
LogGroupNamePrefix: utils.Pointer("prefix"),
})
assert.NoError(t, err)
mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{
Limit: utils.Pointer(int64(0)),
LogGroupNamePrefix: utils.Pointer("prefix"),
})
})
t.Run("Should should not override prefix is there is no logGroupNamePattern", func(t *testing.T) {
mockLogsAPI := &mocks.LogsAPI{}
mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil)
service := NewLogGroupsService(mockLogsAPI, true)
_, err := service.GetLogGroups(resources.LogGroupsRequest{
ResourceRequest: resources.ResourceRequest{
AccountId: utils.Pointer("accountId"),
},
LogGroupNamePrefix: utils.Pointer("prefix"),
})
assert.NoError(t, err)
mockLogsAPI.AssertCalled(t, "DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{
AccountIdentifiers: []*string{utils.Pointer("accountId")},
IncludeLinkedAccounts: utils.Pointer(true),
Limit: utils.Pointer(int64(0)),
LogGroupNamePrefix: utils.Pointer("prefix"),
})
})
}

View File

@@ -0,0 +1,27 @@
package services
import (
"strings"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
)
func valuesToListMetricRespone[T any](values []T) []resources.ResourceResponse[T] {
var response []resources.ResourceResponse[T]
for _, value := range values {
response = append(response, resources.ResourceResponse[T]{Value: value})
}
return response
}
func getAccountId(arn string) string {
// format: arn:partition:service:region:account-id:resource-id
parts := strings.Split(arn, ":")
if len(parts) >= 4 {
return parts[4]
}
return ""
}

View File

@@ -31,7 +31,9 @@ func (e *cloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, logger
return nil, fmt.Errorf("invalid time range: start time must be before end time")
}
requestQueries, err := models.ParseMetricDataQueries(req.Queries, startTime, endTime, e.features.IsEnabled(featuremgmt.FlagCloudWatchDynamicLabels))
requestQueries, err := models.ParseMetricDataQueries(req.Queries, startTime, endTime,
e.features.IsEnabled(featuremgmt.FlagCloudWatchDynamicLabels),
e.features.IsEnabled(featuremgmt.FlagCloudWatchCrossAccountQuerying))
if err != nil {
return nil, err
}

View File

@@ -588,3 +588,186 @@ func Test_QueryData_response_data_frame_names(t *testing.T) {
})
}
}
func TestTimeSeriesQuery_CrossAccountQuerying(t *testing.T) {
origNewCWClient := NewCWClient
t.Cleanup(func() {
NewCWClient = origNewCWClient
})
var api mocks.MetricsAPI
NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI {
return &api
}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return DataSource{Settings: models.CloudWatchSettings{}}, nil
})
t.Run("should call GetMetricDataInput with AccountId nil when no AccountId is provided", func(t *testing.T) {
api = mocks.MetricsAPI{}
api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil)
executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying))
_, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
PluginContext: backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
},
Queries: []backend.DataQuery{
{
RefID: "A",
TimeRange: backend.TimeRange{From: time.Now().Add(time.Hour * -2), To: time.Now().Add(time.Hour * -1)},
JSON: json.RawMessage(`{
"type": "timeSeriesQuery",
"subtype": "metrics",
"namespace": "AWS/EC2",
"metricName": "NetworkOut",
"dimensions": {
"InstanceId": "i-00645d91ed77d87ac"
},
"region": "us-east-2",
"id": "a",
"alias": "NetworkOut",
"statistic": "Maximum",
"period": "300",
"hide": false,
"matchExact": true,
"refId": "A"
}`),
},
},
})
require.NoError(t, err)
actualInput, ok := api.Calls[0].Arguments[1].(*cloudwatch.GetMetricDataInput)
require.True(t, ok)
require.Len(t, actualInput.MetricDataQueries, 1)
assert.Nil(t, actualInput.MetricDataQueries[0].Expression)
assert.Nil(t, actualInput.MetricDataQueries[0].AccountId)
})
t.Run("should call GetMetricDataInput with AccountId nil when feature flag is false", func(t *testing.T) {
api = mocks.MetricsAPI{}
api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil)
executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures())
_, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
PluginContext: backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
},
Queries: []backend.DataQuery{
{
RefID: "A",
TimeRange: backend.TimeRange{From: time.Now().Add(time.Hour * -2), To: time.Now().Add(time.Hour * -1)},
JSON: json.RawMessage(`{
"type": "timeSeriesQuery",
"subtype": "metrics",
"namespace": "AWS/EC2",
"metricName": "NetworkOut",
"dimensions": {
"InstanceId": "i-00645d91ed77d87ac"
},
"region": "us-east-2",
"id": "a",
"alias": "NetworkOut",
"statistic": "Maximum",
"period": "300",
"hide": false,
"matchExact": true,
"refId": "A",
"accountId":"some account Id"
}`),
},
},
})
require.NoError(t, err)
actualInput, ok := api.Calls[0].Arguments[1].(*cloudwatch.GetMetricDataInput)
require.True(t, ok)
require.Len(t, actualInput.MetricDataQueries, 1)
assert.Nil(t, actualInput.MetricDataQueries[0].Expression)
assert.Nil(t, actualInput.MetricDataQueries[0].AccountId)
})
t.Run("should call GetMetricDataInput with AccountId in a MetricStat query", func(t *testing.T) {
api = mocks.MetricsAPI{}
api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil)
executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying))
_, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
PluginContext: backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
},
Queries: []backend.DataQuery{
{
RefID: "A",
TimeRange: backend.TimeRange{From: time.Now().Add(time.Hour * -2), To: time.Now().Add(time.Hour * -1)},
JSON: json.RawMessage(`{
"type": "timeSeriesQuery",
"subtype": "metrics",
"namespace": "AWS/EC2",
"metricName": "NetworkOut",
"dimensions": {
"InstanceId": "i-00645d91ed77d87ac"
},
"region": "us-east-2",
"id": "a",
"alias": "NetworkOut",
"statistic": "Maximum",
"period": "300",
"hide": false,
"matchExact": true,
"refId": "A",
"accountId":"some account Id"
}`),
},
},
})
require.NoError(t, err)
actualInput, ok := api.Calls[0].Arguments[1].(*cloudwatch.GetMetricDataInput)
require.True(t, ok)
require.Len(t, actualInput.MetricDataQueries, 1)
require.NotNil(t, actualInput.MetricDataQueries[0].AccountId)
assert.Equal(t, "some account Id", *actualInput.MetricDataQueries[0].AccountId)
})
t.Run("should GetMetricDataInput with AccountId in an inferred search expression query", func(t *testing.T) {
api = mocks.MetricsAPI{}
api.On("GetMetricDataWithContext", mock.Anything, mock.Anything, mock.Anything).Return(&cloudwatch.GetMetricDataOutput{}, nil)
executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures(featuremgmt.FlagCloudWatchCrossAccountQuerying))
_, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
PluginContext: backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
},
Queries: []backend.DataQuery{
{
RefID: "A",
TimeRange: backend.TimeRange{From: time.Now().Add(time.Hour * -2), To: time.Now().Add(time.Hour * -1)},
JSON: json.RawMessage(`{
"type": "timeSeriesQuery",
"subtype": "metrics",
"namespace": "AWS/EC2",
"metricName": "NetworkOut",
"dimensions": {
"InstanceId": "*"
},
"region": "us-east-2",
"id": "a",
"alias": "NetworkOut",
"statistic": "Maximum",
"period": "300",
"hide": false,
"matchExact": true,
"refId": "A",
"accountId":"some account Id"
}`),
},
},
})
require.NoError(t, err)
actualInput, ok := api.Calls[0].Arguments[1].(*cloudwatch.GetMetricDataInput)
require.True(t, ok)
require.Len(t, actualInput.MetricDataQueries, 1)
require.NotNil(t, actualInput.MetricDataQueries[0].Expression)
assert.Equal(t, `REMOVE_EMPTY(SEARCH('{"AWS/EC2","InstanceId"} MetricName="NetworkOut" :aws.AccountId="some account Id"', 'Maximum', 300))`, *actualInput.MetricDataQueries[0].Expression)
})
}

View File

@@ -0,0 +1,3 @@
package utils
func Pointer[T any](arg T) *T { return &arg }

View File

@@ -166,9 +166,6 @@ func (c fakeRGTAClient) GetResourcesPages(in *resourcegroupstaggingapi.GetResour
}
type fakeCheckHealthClient struct {
cloudwatchiface.CloudWatchAPI
cloudwatchlogsiface.CloudWatchLogsAPI
listMetricsPages func(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error
describeLogGroups func(input *cloudwatchlogs.DescribeLogGroupsInput) (*cloudwatchlogs.DescribeLogGroupsOutput, error)
}

View File

@@ -0,0 +1,39 @@
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { Icon, Input, useStyles2 } from '@grafana/ui';
import getStyles from './components/styles';
// TODO: consider moving search into grafana/ui, this is mostly the same as that in azure monitor
const Search = ({ searchFn, searchPhrase }: { searchPhrase: string; searchFn: (searchPhrase: string) => void }) => {
const [searchFilter, setSearchFilter] = useState(searchPhrase);
const styles = useStyles2(getStyles);
const debouncedSearch = useMemo(() => debounce(searchFn, 600), [searchFn]);
useEffect(() => {
return () => {
// Stop the invocation of the debounced function after unmounting
debouncedSearch?.cancel();
};
}, [debouncedSearch]);
return (
<Input
className={styles.search}
width={64}
aria-label="log group search"
prefix={<Icon name="search" />}
value={searchFilter}
onChange={(event) => {
const searchPhrase = event.currentTarget.value;
setSearchFilter(searchPhrase);
debouncedSearch(searchPhrase);
}}
placeholder="search by log group name prefix"
/>
);
};
export default Search;

View File

@@ -71,7 +71,7 @@ export function setupMockedDataSource({
if (variables) {
templateService = setupMockedTemplateService(variables);
if (mockGetVariableName) {
templateService.getVariableName = (name: string) => name;
templateService.getVariableName = (name: string) => name.replace('$', '');
}
}
@@ -83,6 +83,7 @@ export function setupMockedDataSource({
datasource.api.getRegions = jest.fn().mockResolvedValue([]);
datasource.api.getDimensionKeys = jest.fn().mockResolvedValue([]);
datasource.api.getMetrics = jest.fn().mockResolvedValue([]);
datasource.api.getAccounts = jest.fn().mockResolvedValue([]);
datasource.logsQueryRunner.defaultLogGroups = [];
const fetchMock = jest.fn().mockReturnValue(of({}));
setBackendSrv({
@@ -241,3 +242,16 @@ export const periodIntervalVariable: CustomVariableModel = {
hide: VariableHide.dontHide,
type: 'custom',
};
export const accountIdVariable: CustomVariableModel = {
...initialCustomVariableModelState,
id: 'accountId',
name: 'accountId',
current: {
value: 'templatedaccountId',
text: 'templatedaccountId',
selected: true,
},
options: [{ value: 'templatedRegion', text: 'templatedRegion', selected: true }],
multi: false,
};

View File

@@ -9,6 +9,31 @@ export const CloudWatchDashboardLoadedEvent = new DashboardLoadedEvent({
grafanaVersion: 'v9.0.0',
queries: {
cloudwatch: [
{
accountId: '123456789',
datasource: {
type: 'cloudwatch',
uid: '123',
},
dimensions: {
InstanceId: 'i-123',
},
expression: '',
hide: false,
id: '',
label: '',
matchExact: true,
metricEditorMode: 0,
metricName: 'CPUUtilization',
metricQueryType: 0,
namespace: 'AWS/EC2',
period: '',
queryMode: 'Metrics',
refId: 'A',
region: 'us-east-1',
sqlExpression: '',
statistic: 'Average',
},
{
alias: '',
datasource: {

View File

@@ -1,6 +1,25 @@
import { QueryEditorExpressionType } from '../expressions';
import { CloudWatchMetricsQuery, MetricQueryType, MetricEditorMode, CloudWatchLogsQuery } from '../types';
export const validMetricsQuery: CloudWatchMetricsQuery = {
export const validMetricSearchCodeQuery: CloudWatchMetricsQuery = {
id: '',
queryMode: 'Metrics',
region: 'us-east-2',
namespace: 'AWS/EC2',
period: '3000',
alias: '',
metricName: 'CPUUtilization',
dimensions: { InstanceId: 'i-123' },
matchExact: true,
statistic: 'Average',
expression: 'SEARCH()',
refId: 'A',
metricQueryType: MetricQueryType.Search,
metricEditorMode: MetricEditorMode.Code,
hide: false,
};
export const validMetricSearchBuilderQuery: CloudWatchMetricsQuery = {
id: '',
queryMode: 'Metrics',
region: 'us-east-2',
@@ -14,6 +33,53 @@ export const validMetricsQuery: CloudWatchMetricsQuery = {
expression: '',
refId: 'A',
metricQueryType: MetricQueryType.Search,
metricEditorMode: MetricEditorMode.Builder,
hide: false,
};
export const validMetricQueryBuilderQuery: CloudWatchMetricsQuery = {
id: '',
queryMode: 'Metrics',
region: 'us-east-2',
namespace: 'AWS/EC2',
period: '3000',
alias: '',
metricName: 'CPUUtilization',
dimensions: { InstanceId: 'i-123' },
matchExact: true,
statistic: 'Average',
sql: {
select: {
type: QueryEditorExpressionType.Function,
name: 'AVERAGE',
parameters: [
{
type: QueryEditorExpressionType.FunctionParameter,
name: 'CPUUtilization',
},
],
},
},
refId: 'A',
metricQueryType: MetricQueryType.Query,
metricEditorMode: MetricEditorMode.Builder,
hide: false,
};
export const validMetricQueryCodeQuery: CloudWatchMetricsQuery = {
id: '',
queryMode: 'Metrics',
region: 'us-east-2',
namespace: 'AWS/EC2',
period: '3000',
alias: '',
metricName: 'CPUUtilization',
dimensions: { InstanceId: 'i-123' },
matchExact: true,
statistic: 'Average',
sqlExpression: 'SELECT * FROM "AWS/EC2" WHERE "InstanceId" = \'i-123\'',
refId: 'A',
metricQueryType: MetricQueryType.Query,
metricEditorMode: MetricEditorMode.Code,
hide: false,
};

View File

@@ -75,12 +75,16 @@ describe('api', () => {
it('when getAllMetrics is called', async () => {
const getMock = jest.fn().mockResolvedValue([
{
namespace: 'AWS/EC2',
name: 'CPUUtilization',
value: {
namespace: 'AWS/EC2',
name: 'CPUUtilization',
},
},
{
namespace: 'AWS/Redshift',
name: 'CPUPercentage',
value: {
namespace: 'AWS/Redshift',
name: 'CPUPercentage',
},
},
]);
const { api } = setupMockedAPI({ getMock });
@@ -94,12 +98,16 @@ describe('api', () => {
it('when getMetrics', async () => {
const getMock = jest.fn().mockResolvedValue([
{
namespace: 'AWS/EC2',
name: 'CPUUtilization',
value: {
namespace: 'AWS/EC2',
name: 'CPUUtilization',
},
},
{
namespace: 'AWS/EC2',
name: 'CPUPercentage',
value: {
namespace: 'AWS/EC2',
name: 'CPUPercentage',
},
},
]);
const { api } = setupMockedAPI({ getMock });

View File

@@ -1,6 +1,6 @@
import { memoize } from 'lodash';
import { DataSourceInstanceSettings, SelectableValue, toOption } from '@grafana/data';
import { DataSourceInstanceSettings, SelectableValue } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { TemplateSrv } from 'app/features/templating/template_srv';
@@ -11,8 +11,12 @@ import {
GetDimensionKeysRequest,
GetDimensionValuesRequest,
GetMetricsRequest,
LogGroupResponse,
MetricResponse,
MultiFilters,
Account,
ResourceRequest,
ResourceResponse,
} from './types';
export interface SelectableResourceValue extends SelectableValue<string> {
@@ -33,6 +37,18 @@ export class CloudWatchAPI extends CloudWatchRequest {
return getBackendSrv().get(`/api/datasources/${this.instanceSettings.id}/resources/${subtype}`, parameters);
}
getAccounts({ region }: ResourceRequest): Promise<Account[]> {
return this.memoizedGetRequest<Array<ResourceResponse<Account>>>('accounts', {
region: this.templateSrv.replace(region),
}).then((accounts) => accounts.map((a) => a.value));
}
isMonitoringAccount(region: string): Promise<boolean> {
return this.getAccounts({ region })
.then((accounts) => accounts.some((account) => account.isMonitoringAccount))
.catch(() => false);
}
getRegions() {
return this.memoizedGetRequest<SelectableResourceValue[]>('regions').then((regions) => [
{ label: 'default', value: 'default', text: 'default' },
@@ -41,8 +57,8 @@ export class CloudWatchAPI extends CloudWatchRequest {
}
getNamespaces() {
return this.memoizedGetRequest<string[]>('namespaces').then((namespaces) =>
namespaces.map((n) => ({ label: n, value: n }))
return this.memoizedGetRequest<Array<ResourceResponse<string>>>('namespaces').then((namespaces) =>
namespaces.map((n) => ({ label: n.value, value: n.value }))
);
}
@@ -53,6 +69,20 @@ export class CloudWatchAPI extends CloudWatchRequest {
});
}
async describeCrossAccountLogGroups(params: DescribeLogGroupsRequest): Promise<SelectableResourceValue[]> {
return this.memoizedGetRequest<Array<ResourceResponse<LogGroupResponse>>>('describe-log-groups', {
...params,
region: this.templateSrv.replace(this.getActualRegion(params.region)),
accountId: this.templateSrv.replace(params.accountId),
}).then((resourceResponse) =>
resourceResponse.map((resource) => ({
label: resource.value.name,
value: resource.value.arn,
text: resource.accountId || '',
}))
);
}
async describeAllLogGroups(params: DescribeLogGroupsRequest) {
return this.memoizedGetRequest<SelectableResourceValue[]>('all-log-groups', {
...params,
@@ -60,21 +90,26 @@ export class CloudWatchAPI extends CloudWatchRequest {
});
}
async getMetrics({ region, namespace }: GetMetricsRequest): Promise<Array<SelectableValue<string>>> {
async getMetrics({ region, namespace, accountId }: GetMetricsRequest): Promise<Array<SelectableValue<string>>> {
if (!namespace) {
return [];
}
return this.memoizedGetRequest<MetricResponse[]>('metrics', {
return this.memoizedGetRequest<Array<ResourceResponse<MetricResponse>>>('metrics', {
region: this.templateSrv.replace(this.getActualRegion(region)),
namespace: this.templateSrv.replace(namespace),
}).then((metrics) => metrics.map((m) => ({ label: m.name, value: m.name })));
accountId: this.templateSrv.replace(accountId),
}).then((metrics) => metrics.map((m) => ({ label: m.value.name, value: m.value.name })));
}
async getAllMetrics({ region }: GetMetricsRequest): Promise<Array<{ metricName?: string; namespace: string }>> {
return this.memoizedGetRequest<MetricResponse[]>('metrics', {
async getAllMetrics({
region,
accountId,
}: GetMetricsRequest): Promise<Array<{ metricName?: string; namespace: string }>> {
return this.memoizedGetRequest<Array<ResourceResponse<MetricResponse>>>('metrics', {
region: this.templateSrv.replace(this.getActualRegion(region)),
}).then((metrics) => metrics.map((m) => ({ metricName: m.name, namespace: m.namespace })));
accountId: this.templateSrv.replace(accountId),
}).then((metrics) => metrics.map((m) => ({ metricName: m.value.name, namespace: m.value.namespace })));
}
async getDimensionKeys({
@@ -82,13 +117,15 @@ export class CloudWatchAPI extends CloudWatchRequest {
namespace = '',
dimensionFilters = {},
metricName = '',
accountId,
}: GetDimensionKeysRequest): Promise<Array<SelectableValue<string>>> {
return this.memoizedGetRequest<string[]>('dimension-keys', {
return this.memoizedGetRequest<Array<ResourceResponse<string>>>('dimension-keys', {
region: this.templateSrv.replace(this.getActualRegion(region)),
namespace: this.templateSrv.replace(namespace),
accountId: this.templateSrv.replace(accountId),
metricName: this.templateSrv.replace(metricName),
dimensionFilters: JSON.stringify(this.convertDimensionFormat(dimensionFilters, {})),
metricName,
}).then((dimensionKeys) => dimensionKeys.map(toOption));
}).then((r) => r.map((r) => ({ label: r.value, value: r.value })));
}
async getDimensionValues({
@@ -97,19 +134,20 @@ export class CloudWatchAPI extends CloudWatchRequest {
namespace,
dimensionFilters = {},
metricName = '',
accountId,
}: GetDimensionValuesRequest) {
if (!namespace || !metricName) {
return [];
}
const values = await this.memoizedGetRequest<string[]>('dimension-values', {
const values = await this.memoizedGetRequest<Array<ResourceResponse<string>>>('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),
dimensionFilters: JSON.stringify(this.convertDimensionFormat(dimensionFilters, {})),
}).then((dimensionValues) => dimensionValues.map(toOption));
accountId: this.templateSrv.replace(accountId),
}).then((r) => r.map((r) => ({ label: r.value, value: r.value })));
return values;
}

View File

@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import selectEvent from 'react-select-event';
import { Account } from './Account';
export const AccountOptions = [
{
value: '123456789',
label: 'test-account1',
description: '123456789',
},
{
value: '432156789013',
label: 'test-account2',
description: '432156789013',
},
{
value: '999999999999',
label: 'test-account3',
description: '999999999999',
},
{
label: 'Template Variables',
options: [
{
value: '$fakeVar',
label: '$fakeVar',
},
],
},
];
describe('Account', () => {
const props = {
accountOptions: AccountOptions,
region: 'us-east-2',
onChange: jest.fn(),
accountId: '123456789012',
};
it('should not render if there are no accounts', async () => {
render(<Account {...props} accountOptions={[]} />);
expect(screen.queryByLabelText('Account Selection')).not.toBeInTheDocument();
});
it('should render a selectable field of accounts if there are accounts', async () => {
const onChange = jest.fn();
render(<Account {...props} onChange={onChange} />);
expect(screen.getByLabelText('Account Selection')).toBeInTheDocument();
await selectEvent.select(screen.getByLabelText('Account Selection'), 'test-account3', { container: document.body });
expect(onChange).toBeCalledWith('999999999999');
});
it("should default to 'all' if there is no selection", () => {
render(<Account {...props} accountId={undefined} />);
expect(screen.getByLabelText('Account Selection')).toBeInTheDocument();
expect(screen.getByText('All')).toBeInTheDocument();
});
it('should select an uninterpolated template variable if it has been selected', () => {
render(<Account {...props} accountId={'$fakeVar'} />);
expect(screen.getByLabelText('Account Selection')).toBeInTheDocument();
expect(screen.getByText('$fakeVar')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,54 @@
import React, { useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField } from '@grafana/experimental';
import { Select } from '@grafana/ui';
export interface Props {
onChange: (accountId?: string) => void;
accountOptions: Array<SelectableValue<string>>;
accountId?: string;
}
export const ALL_ACCOUNTS_OPTION = {
label: 'All',
value: 'all',
description: 'Target all linked accounts',
};
export function Account({ accountId, onChange, accountOptions }: Props) {
const selectedAccountExistsInOptions = useMemo(
() =>
accountOptions.find((a) => {
if (a.options) {
const matchingTemplateVar = a.options.find((tempVar: SelectableValue<string>) => {
return tempVar.value === accountId;
});
return matchingTemplateVar;
}
return a.value === accountId;
}),
[accountOptions, accountId]
);
if (accountOptions.length === 0) {
return null;
}
return (
<EditorField
label="Account"
width={26}
tooltip="A CloudWatch monitoring account views data from source accounts so you can centralize monitoring and troubleshooting activities across multiple accounts. Go to the CloudWatch settings page in the AWS console for more details."
>
<Select
aria-label="Account Selection"
value={selectedAccountExistsInOptions ? accountId : ALL_ACCOUNTS_OPTION.value}
options={[ALL_ACCOUNTS_OPTION, ...accountOptions]}
onChange={({ value }) => {
onChange(value);
}}
/>
</EditorField>
);
}

View File

@@ -70,4 +70,11 @@ describe('AnnotationQueryEditor', () => {
expect(screen.queryByText('*')).toBeNull();
});
});
it('should not display Accounts component', async () => {
ds.datasource.api.getDimensionValues = jest.fn().mockResolvedValue([[{ label: 'dimVal1', value: 'dimVal1' }]]);
(props.query as CloudWatchAnnotationQuery).dimensions = { instanceId: 'instance-123' };
await waitFor(() => render(<AnnotationQueryEditor {...props} />));
expect(await screen.queryByText('Account')).toBeNull();
});
});

View File

@@ -0,0 +1,190 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// eslint-disable-next-line lodash/import-scope
import lodash from 'lodash';
import React from 'react';
import selectEvent from 'react-select-event';
import { CrossAccountLogsQueryField } from './CrossAccountLogsQueryField';
const defaultProps = {
selectedLogGroups: [],
accountOptions: [
{
value: 'account-id123',
descriptions: 'account-id123',
label: 'Account Name 123',
},
{
value: 'account-id456',
descriptions: 'account-id456',
label: 'Account Name 456',
},
],
fetchLogGroups: () =>
Promise.resolve([
{
label: 'logGroup1',
text: 'logGroup1',
value: 'arn:partition:service:region:account-id123:loggroup:someloggroup',
},
{
label: 'logGroup2',
text: 'logGroup2',
value: 'arn:partition:service:region:account-id456:loggroup:someotherloggroup',
},
]),
onChange: jest.fn(),
onRunQuery: jest.fn(),
};
const originalDebounce = lodash.debounce;
class Deferred {
promise: Promise<unknown>;
resolve!: (value?: unknown) => void;
reject: ((reason?: unknown) => void) | undefined;
constructor() {
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}
describe('CrossAccountLogsQueryField', () => {
beforeEach(() => {
lodash.debounce = jest.fn().mockImplementation((fn) => {
fn.cancel = () => {};
return fn;
});
});
afterEach(() => {
lodash.debounce = originalDebounce;
});
it('opens a modal with a search field when the Select Log Groups Button is clicked', async () => {
render(<CrossAccountLogsQueryField {...defaultProps} />);
await userEvent.click(screen.getByText('Select Log Groups'));
expect(screen.getByText('Log Group Name')).toBeInTheDocument();
});
it('calls fetchLogGroups the first time the modal opens and renders a loading widget and then a checkbox for every log group', async () => {
const defer = new Deferred();
const fetchLogGroups = jest.fn(async () => {
await Promise.all([defer.promise]);
return defaultProps.fetchLogGroups();
});
render(<CrossAccountLogsQueryField {...defaultProps} fetchLogGroups={fetchLogGroups} />);
await userEvent.click(screen.getByText('Select Log Groups'));
expect(screen.getByText('Loading...')).toBeInTheDocument();
defer.resolve();
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
expect(fetchLogGroups).toBeCalledTimes(1);
expect(screen.getAllByRole('checkbox').length).toBe(2);
});
it('returns a no log groups found message when fetchLogGroups returns an empty array', async () => {
const defer = new Deferred();
const fetchLogGroups = jest.fn(async () => {
await Promise.all([defer.promise]);
return [];
});
render(<CrossAccountLogsQueryField {...defaultProps} fetchLogGroups={fetchLogGroups} />);
await userEvent.click(screen.getByText('Select Log Groups'));
expect(screen.getByText('Loading...')).toBeInTheDocument();
defer.resolve();
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
expect(fetchLogGroups).toBeCalledTimes(1);
expect(screen.queryAllByRole('checkbox').length).toBe(0);
expect(screen.getByText('No log groups found')).toBeInTheDocument();
});
it('calls fetchLogGroups with a search phrase when it is typed in the Search Field', async () => {
const fetchLogGroups = jest.fn(() => defaultProps.fetchLogGroups());
render(<CrossAccountLogsQueryField {...defaultProps} fetchLogGroups={fetchLogGroups} />);
await userEvent.click(screen.getByText('Select Log Groups'));
expect(screen.getByText('Log Group Name')).toBeInTheDocument();
await userEvent.type(screen.getByLabelText('log group search'), 'something');
await waitFor(() => screen.getByDisplayValue('something'));
expect(fetchLogGroups).toBeCalledWith({ accountId: 'all', logGroupPattern: 'something' });
});
it('calls fetchLogGroups with an account when selected', async () => {
const firstCall = new Deferred();
const secondCall = new Deferred();
let once = false;
const fetchLogGroups = jest.fn(async () => {
if (once) {
await Promise.all([secondCall.promise]);
return defaultProps.fetchLogGroups();
}
await Promise.all([firstCall.promise]);
once = true;
return defaultProps.fetchLogGroups();
});
render(<CrossAccountLogsQueryField {...defaultProps} fetchLogGroups={fetchLogGroups} />);
await userEvent.click(screen.getByText('Select Log Groups'));
expect(screen.getByText('Loading...')).toBeInTheDocument();
firstCall.resolve();
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
expect(fetchLogGroups).toBeCalledTimes(1);
expect(screen.getAllByRole('checkbox').length).toBe(2);
await selectEvent.select(screen.getByLabelText('Account Selection'), 'Account Name 123', {
container: document.body,
});
expect(screen.getByText('Loading...')).toBeInTheDocument();
secondCall.resolve();
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
expect(fetchLogGroups).toBeCalledWith({ accountId: 'account-id123', logGroupPattern: '' });
});
it('shows a log group as checked after the user checks it', async () => {
const onChange = jest.fn();
render(<CrossAccountLogsQueryField {...defaultProps} onChange={onChange} />);
await userEvent.click(screen.getByText('Select Log Groups'));
expect(screen.getByText('Log Group Name')).toBeInTheDocument();
expect(screen.getByLabelText('logGroup2')).not.toBeChecked();
await userEvent.click(screen.getByLabelText('logGroup2'));
expect(screen.getByLabelText('logGroup2')).toBeChecked();
});
it('calls onChange with the selected log group when checked and the user clicks the Add button', async () => {
const onChange = jest.fn();
render(<CrossAccountLogsQueryField {...defaultProps} onChange={onChange} />);
await userEvent.click(screen.getByText('Select Log Groups'));
expect(screen.getByText('Log Group Name')).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('logGroup2'));
await userEvent.click(screen.getByText('Add log groups'));
expect(onChange).toHaveBeenCalledWith([
{
label: 'logGroup2',
text: 'logGroup2',
value: 'arn:partition:service:region:account-id456:loggroup:someotherloggroup',
},
]);
});
it('does not call onChange after a selection if the user hits the cancel button', async () => {
const onChange = jest.fn();
render(<CrossAccountLogsQueryField {...defaultProps} onChange={onChange} />);
await userEvent.click(screen.getByText('Select Log Groups'));
expect(screen.getByText('Log Group Name')).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('logGroup2'));
await userEvent.click(screen.getByText('Cancel'));
expect(onChange).not.toHaveBeenCalledWith([
{
label: 'logGroup2',
text: 'logGroup2',
value: 'arn:partition:service:region:account-id456:loggroup:someotherloggroup',
},
]);
});
it('runs the query on close of the modal', async () => {
const onRunQuery = jest.fn();
render(<CrossAccountLogsQueryField {...defaultProps} onRunQuery={onRunQuery} />);
await userEvent.click(screen.getByText('Select Log Groups'));
expect(screen.getByText('Log Group Name')).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Close dialogue'));
expect(onRunQuery).toBeCalledTimes(1);
});
});

View File

@@ -0,0 +1,179 @@
import React, { useMemo, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField } from '@grafana/experimental';
import { Button, Checkbox, IconButton, LoadingPlaceholder, Modal, useStyles2 } from '@grafana/ui';
import Search from '../Search';
import { SelectableResourceValue } from '../api';
import { DescribeLogGroupsRequest } from '../types';
import { Account, ALL_ACCOUNTS_OPTION } from './Account';
import getStyles from './styles';
type CrossAccountLogsQueryProps = {
selectedLogGroups: SelectableResourceValue[];
accountOptions: Array<SelectableValue<string>>;
fetchLogGroups: (params: Partial<DescribeLogGroupsRequest>) => Promise<SelectableResourceValue[]>;
onChange: (selectedLogGroups: SelectableResourceValue[]) => void;
onRunQuery: () => void;
};
export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectableLogGroups, setSelectableLogGroups] = useState<SelectableResourceValue[]>([]);
const [selectedLogGroups, setSelectedLogGroups] = useState(props.selectedLogGroups);
const [searchPhrase, setSearchPhrase] = useState('');
const [searchAccountId, setSearchAccountId] = useState(ALL_ACCOUNTS_OPTION.value);
const [isLoading, setIsLoading] = useState(false);
const styles = useStyles2(getStyles);
const toggleModal = () => {
setIsModalOpen(!isModalOpen);
if (isModalOpen) {
props.onRunQuery();
} else {
setSelectedLogGroups(props.selectedLogGroups);
searchFn(searchPhrase, searchAccountId);
}
};
const searchFn = async (searchTerm?: string, accountId?: string) => {
setIsLoading(true);
try {
const possibleLogGroups = await props.fetchLogGroups({
logGroupPattern: searchTerm,
accountId: accountId,
});
setSelectableLogGroups(possibleLogGroups);
} catch (err) {
setSelectableLogGroups([]);
}
setIsLoading(false);
};
const handleSelectCheckbox = (row: SelectableResourceValue, isChecked: boolean) => {
if (isChecked) {
setSelectedLogGroups([...selectedLogGroups, row]);
} else {
setSelectedLogGroups(selectedLogGroups.filter((lg) => lg.value !== row.value));
}
};
const handleApply = () => {
props.onChange(selectedLogGroups);
toggleModal();
};
const handleCancel = () => {
setSelectedLogGroups(props.selectedLogGroups);
toggleModal();
};
const accountNameById = useMemo(() => {
const idsToNames: Record<string, string> = {};
props.accountOptions.forEach((a) => {
if (a.value && a.label) {
idsToNames[a.value] = a.label;
}
});
return idsToNames;
}, [props.accountOptions]);
return (
<>
<Modal className={styles.modal} title="Select Log Groups" isOpen={isModalOpen} onDismiss={toggleModal}>
<div className={styles.logGroupSelectionArea}>
<EditorField label="Log Group Name">
<Search
searchFn={(phrase) => {
searchFn(phrase, searchAccountId);
setSearchPhrase(phrase);
}}
searchPhrase={searchPhrase}
/>
</EditorField>
<Account
onChange={(accountId?: string) => {
searchFn(searchPhrase, accountId);
setSearchAccountId(accountId || ALL_ACCOUNTS_OPTION.value);
}}
accountOptions={props.accountOptions}
accountId={searchAccountId}
/>
</div>
<div>
<div className={styles.tableScroller}>
<table className={styles.table}>
<thead>
<tr className={styles.row}>
<td className={styles.cell}>Log Group</td>
<td className={styles.cell}>Account name</td>
<td className={styles.cell}>Account ID</td>
</tr>
</thead>
<tbody>
{isLoading && (
<tr className={styles.row}>
<td className={styles.cell}>
<LoadingPlaceholder text={'Loading...'} />
</td>
</tr>
)}
{!isLoading && selectableLogGroups.length === 0 && (
<tr className={styles.row}>
<td className={styles.cell}>No log groups found</td>
</tr>
)}
{!isLoading &&
selectableLogGroups.map((row) => (
<tr className={styles.row} key={`${row.value}`}>
<td className={styles.cell}>
<Checkbox
id={row.value}
onChange={(ev) => handleSelectCheckbox(row, ev.currentTarget.checked)}
value={!!(row.value && selectedLogGroups.some((lg) => lg.value === row.value))}
/>
<label className={styles.logGroupSearchResults} htmlFor={row.value}>
{row.label}
</label>
</td>
<td className={styles.cell}>{accountNameById[row.text]}</td>
<td className={styles.cell}>{row.text}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div>
<Button onClick={handleApply} type="button" className={styles.addBtn}>
Add log groups
</Button>
<Button onClick={handleCancel} type="button">
Cancel
</Button>
</div>
</Modal>
<div>
<Button variant="secondary" onClick={toggleModal} type="button">
Select Log Groups
</Button>
</div>
<div>
{props.selectedLogGroups.map((lg) => (
<div key={lg.value} className={styles.selectedLogGroup}>
{lg.label}
<IconButton
size="sm"
name="times"
className={styles.removeButton}
onClick={() => props.onChange(props.selectedLogGroups.filter((slg) => slg.value !== lg.value))}
/>
</div>
))}
</div>
</>
);
};

View File

@@ -34,7 +34,7 @@ const excludeCurrentKey = (dimensions: Dimensions, currentKey: string | undefine
export const FilterItem: FunctionComponent<Props> = ({
filter,
metricStat: { region, namespace, metricName, dimensions },
metricStat: { region, namespace, metricName, dimensions, accountId },
datasource,
dimensionKeys,
disableExpressions,
@@ -58,6 +58,7 @@ export const FilterItem: FunctionComponent<Props> = ({
region,
namespace,
metricName,
accountId,
})
.then((result: Array<SelectableValue<string>>) => {
if (result.length && !disableExpressions && !result.some((o) => o.value === wildcardOption.value)) {
@@ -67,7 +68,14 @@ export const FilterItem: FunctionComponent<Props> = ({
});
};
const [state, loadOptions] = useAsyncFn(loadDimensionValues, [filter.key, dimensions]);
const [state, loadOptions] = useAsyncFn(loadDimensionValues, [
filter.key,
dimensions,
region,
namespace,
metricName,
accountId,
]);
const theme = useTheme2();
const styles = getOperatorStyles(theme);

View File

@@ -0,0 +1,86 @@
import { render, screen, waitFor } from '@testing-library/react';
// eslint-disable-next-line lodash/import-scope
import lodash from 'lodash';
import React from 'react';
import { config } from '@grafana/runtime';
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
import { CloudWatchLogsQuery } from '../types';
import { LogGroupSelection } from './LogGroupSelection';
const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying;
const originalDebounce = lodash.debounce;
const defaultProps = {
datasource: setupMockedDataSource().datasource,
query: {
queryMode: 'Logs',
id: '',
region: '',
refId: '',
} as CloudWatchLogsQuery,
onChange: jest.fn(),
onRunQuery: jest.fn(),
};
describe('LogGroupSelection', () => {
beforeEach(() => {
lodash.debounce = jest.fn().mockImplementation((fn) => {
fn.cancel = () => {};
return fn;
});
});
afterEach(() => {
config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue;
lodash.debounce = originalDebounce;
});
it('renders the old logGroupSelector when the feature toggle is disabled and there are no linked accounts', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = false;
render(<LogGroupSelection {...defaultProps} />);
await waitFor(() => screen.getByText('Choose Log Groups'));
expect(screen.queryByText('Select Log Groups')).not.toBeInTheDocument();
});
it('renders the old logGroupSelector when the feature toggle is disabled but there are linked accounts', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = false;
const ds = setupMockedDataSource().datasource;
ds.api.getAccounts = () =>
Promise.resolve([
{
arn: 'arn',
id: 'accountId',
label: 'label',
isMonitoringAccount: true,
},
]);
render(<LogGroupSelection {...defaultProps} datasource={ds} />);
await waitFor(() => screen.getByText('Choose Log Groups'));
expect(screen.queryByText('Select Log Groups')).not.toBeInTheDocument();
});
it('renders the old logGroupSelector when the feature toggle is enabled but there are no linked accounts', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = true;
render(<LogGroupSelection {...defaultProps} />);
await waitFor(() => screen.getByText('Choose Log Groups'));
expect(screen.queryByText('Select Log Groups')).not.toBeInTheDocument();
});
it('renders the new logGroupSelector when the feature toggle is enabled and there are linked accounts', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = true;
const ds = setupMockedDataSource().datasource;
ds.api.getAccounts = () =>
Promise.resolve([
{
arn: 'arn',
id: 'accountId',
label: 'label',
isMonitoringAccount: true,
},
]);
render(<LogGroupSelection {...defaultProps} datasource={ds} />);
await waitFor(() => screen.getByText('Select Log Groups'));
expect(screen.queryByText('Choose Log Groups')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,64 @@
import { css } from '@emotion/css';
import React from 'react';
import { config } from '@grafana/runtime';
import { LegacyForms } from '@grafana/ui';
import { SelectableResourceValue } from '../api';
import { CloudWatchDatasource } from '../datasource';
import { useAccountOptions } from '../hooks';
import { CloudWatchLogsQuery, CloudWatchQuery, DescribeLogGroupsRequest } from '../types';
import { CrossAccountLogsQueryField } from './CrossAccountLogsQueryField';
import { LogGroupSelector } from './LogGroupSelector';
type Props = {
datasource: CloudWatchDatasource;
query: CloudWatchLogsQuery;
onChange: (value: CloudWatchQuery) => void;
onRunQuery: () => void;
};
const rowGap = css`
gap: 3px;
`;
export const LogGroupSelection = ({ datasource, query, onChange, onRunQuery }: Props) => {
const accountState = useAccountOptions(datasource.api, query.region);
return (
<div className={`gf-form gf-form--grow flex-grow-1 ${rowGap}`}>
{config.featureToggles.cloudWatchCrossAccountQuerying && accountState?.value?.length ? (
<CrossAccountLogsQueryField
fetchLogGroups={(params: Partial<DescribeLogGroupsRequest>) =>
datasource.api.describeCrossAccountLogGroups({ region: query.region, ...params })
}
onChange={(selectedLogGroups: SelectableResourceValue[]) => {
onChange({ ...query, logGroups: selectedLogGroups, logGroupNames: [] });
}}
accountOptions={accountState.value}
onRunQuery={onRunQuery}
selectedLogGroups={query.logGroups ?? []} /* todo handle defaults */
/>
) : (
<LegacyForms.FormField
label="Log Groups"
labelWidth={6}
className="flex-grow-1"
inputEl={
<LogGroupSelector
region={query.region}
selectedLogGroups={query.logGroupNames ?? datasource.logsQueryRunner.defaultLogGroups}
datasource={datasource}
onChange={function (logGroupNames: string[]): void {
onChange({ ...query, logGroupNames, logGroups: [] });
}}
onRunQuery={onRunQuery}
refId={query.refId}
/>
}
/>
)}
</div>
);
};

View File

@@ -109,6 +109,12 @@ export const LogGroupSelector: React.FC<LogGroupSelectorProps> = ({
// Config editor does not fetch new log group options unless changes have been saved
saved && getAvailableLogGroupOptions();
// if component unmounts in the middle of setting state, we reset state and unsubscribe from fetchLogGroupOptions
return () => {
setAvailableLogGroups([]);
setLoadingLogGroups(false);
};
// this hook shouldn't get called every time selectedLogGroups or onChange updates
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [datasource, region, saved]);

View File

@@ -7,7 +7,6 @@ import { Editor } from 'slate-react';
import { AbsoluteTimeRange, QueryEditorProps } from '@grafana/data';
import {
BracesPlugin,
LegacyForms,
QueryField,
SlatePrism,
TypeaheadInput,
@@ -26,7 +25,7 @@ import syntax from '../syntax';
import { CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchQuery } from '../types';
import { getStatsGroups } from '../utils/query/getStatsGroups';
import { LogGroupSelector } from './LogGroupSelector';
import { LogGroupSelection } from './LogGroupSelection';
import QueryHeader from './QueryHeader';
export interface CloudWatchLogsQueryFieldProps
@@ -39,14 +38,9 @@ export interface CloudWatchLogsQueryFieldProps
query: CloudWatchLogsQuery;
}
const rowGap = css`
gap: 3px;
`;
const addPaddingToButton = css`
padding: 1px 4px;
`;
interface State {
hint:
| {
@@ -128,7 +122,7 @@ class CloudWatchLogsQueryField extends React.PureComponent<CloudWatchLogsQueryFi
render() {
const { onRunQuery, onChange, ExtraFieldElement, data, query, datasource, theme } = this.props;
const { region, refId, expression, logGroupNames } = query;
const { expression } = query;
const { hint } = this.state;
const showError = data && data.error && data.error.refId === query.refId;
@@ -143,25 +137,7 @@ class CloudWatchLogsQueryField extends React.PureComponent<CloudWatchLogsQueryFi
onChange={onChange}
sqlCodeEditorIsDirty={false}
/>
<div className={`gf-form gf-form--grow flex-grow-1 ${rowGap}`}>
<LegacyForms.FormField
label="Log Groups"
labelWidth={6}
className="flex-grow-1"
inputEl={
<LogGroupSelector
region={region}
selectedLogGroups={logGroupNames ?? datasource.logsQueryRunner.defaultLogGroups}
datasource={datasource}
onChange={function (logGroups: string[]): void {
onChange({ ...query, logGroupNames: logGroups });
}}
onRunQuery={onRunQuery}
refId={refId}
/>
}
/>
</div>
<LogGroupSelection datasource={datasource} query={query} onChange={onChange} onRunQuery={onRunQuery} />
<div className="gf-form-inline gf-form-inline--nowrap flex-grow-1">
<div className="gf-form gf-form--grow flex-shrink-1">
<QueryField
@@ -173,7 +149,6 @@ class CloudWatchLogsQueryField extends React.PureComponent<CloudWatchLogsQueryFi
cleanText={cleanText}
placeholder="Enter a CloudWatch Logs Insights query (run with Shift+Enter)"
portalOrigin="cloudwatch"
disabled={!logGroupNames || logGroupNames.length === 0}
/>
</div>
{ExtraFieldElement}

View File

@@ -3,10 +3,14 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import selectEvent from 'react-select-event';
import { config } from '@grafana/runtime';
import { MetricStatEditor } from '..';
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
import { validMetricSearchBuilderQuery } from '../../__mocks__/queries';
import { MetricStat } from '../../types';
const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying;
const ds = setupMockedDataSource({
variables: [],
});
@@ -33,6 +37,9 @@ const props = {
};
describe('MetricStatEditor', () => {
afterEach(() => {
config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue;
});
describe('statistics field', () => {
test.each([['Average', 'p23.23', 'p34', '$statistic']])('should accept valid values', async (statistic) => {
const onChange = jest.fn();
@@ -199,4 +206,53 @@ describe('MetricStatEditor', () => {
expect(await screen.findByText(expected)).toBeInTheDocument();
});
});
describe('account id', () => {
it('should set value to "all" when its a monitoring account and no account id is defined in the query', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = true;
const onChange = jest.fn();
props.datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(true);
props.datasource.api.getAccounts = jest.fn().mockResolvedValue([
{
value: '123456789',
label: 'test-account1',
description: '123456789',
},
{
value: '432156789013',
label: 'test-account2',
description: '432156789013',
},
]);
await act(async () => {
render(
<MetricStatEditor
{...props}
metricStat={{ ...validMetricSearchBuilderQuery, accountId: undefined }}
onChange={onChange}
/>
);
});
expect(onChange).toHaveBeenCalledWith({ ...validMetricSearchBuilderQuery, accountId: 'all' });
expect(await screen.findByText('Account')).toBeInTheDocument();
});
it('should unset value when no accounts were found and an account id is defined in the query', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = true;
const onChange = jest.fn();
props.datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(false);
props.datasource.api.getAccounts = jest.fn().mockResolvedValue([]);
await act(async () => {
render(
<MetricStatEditor
{...props}
metricStat={{ ...validMetricSearchBuilderQuery, accountId: '123456789' }}
onChange={onChange}
/>
);
});
expect(onChange).toHaveBeenCalledWith({ ...validMetricSearchBuilderQuery, accountId: undefined });
expect(await screen.queryByText('Account')).not.toBeInTheDocument();
});
});
});

View File

@@ -1,15 +1,17 @@
import React from 'react';
import React, { useEffect } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorFieldGroup, EditorRow, EditorRows, EditorSwitch } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Select } from '@grafana/ui';
import { Dimensions } from '..';
import { CloudWatchDatasource } from '../../datasource';
import { useDimensionKeys, useMetrics, useNamespaces } from '../../hooks';
import { useAccountOptions, useDimensionKeys, useMetrics, useNamespaces } from '../../hooks';
import { standardStatistics } from '../../standardStatistics';
import { MetricStat } from '../../types';
import { appendTemplateVariables, toOption } from '../../utils/utils';
import { Account } from '../Account';
export type Props = {
refId: string;
@@ -28,10 +30,22 @@ export function MetricStatEditor({
onChange,
onRunQuery,
}: React.PropsWithChildren<Props>) {
const { region, namespace } = metricStat;
const namespaces = useNamespaces(datasource);
const metrics = useMetrics(datasource, region, namespace);
const metrics = useMetrics(datasource, metricStat);
const dimensionKeys = useDimensionKeys(datasource, { ...metricStat, dimensionFilters: metricStat.dimensions });
const accountState = useAccountOptions(datasource.api, metricStat.region);
useEffect(() => {
datasource.api.isMonitoringAccount(metricStat.region).then((isMonitoringAccount) => {
if (isMonitoringAccount && !accountState.loading && accountState.value?.length && !metricStat.accountId) {
onChange({ ...metricStat, accountId: 'all' });
}
if (!accountState.loading && accountState.value && !accountState.value.length && metricStat.accountId) {
onChange({ ...metricStat, accountId: undefined });
}
});
}, [accountState, metricStat, onChange, datasource.api]);
const onMetricStatChange = (metricStat: MetricStat) => {
onChange(metricStat);
@@ -59,6 +73,16 @@ export function MetricStatEditor({
return (
<EditorRows>
<EditorRow>
{!disableExpressions && config.featureToggles.cloudWatchCrossAccountQuerying && (
<Account
accountId={metricStat.accountId}
onChange={(accountId?: string) => {
onChange({ ...metricStat, accountId });
onRunQuery();
}}
accountOptions={accountState?.value || []}
></Account>
)}
<EditorFieldGroup>
<EditorField label="Namespace" width={26}>
<Select
@@ -123,9 +147,7 @@ export function MetricStatEditor({
datasource={datasource}
/>
</EditorField>
</EditorRow>
{!disableExpressions && (
<EditorRow>
{!disableExpressions && (
<EditorField
label="Match exact"
optional={true}
@@ -142,8 +164,8 @@ export function MetricStatEditor({
}}
/>
</EditorField>
</EditorRow>
)}
)}
</EditorRow>
</EditorRows>
);
}

View File

@@ -50,6 +50,7 @@ const setup = () => {
datasource.api.getMetrics = jest.fn().mockResolvedValue([]);
datasource.api.getRegions = jest.fn().mockResolvedValue([]);
datasource.api.getDimensionKeys = jest.fn().mockResolvedValue([]);
datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(false);
const props: Props = {
query: {

View File

@@ -40,6 +40,7 @@ describe('MetricsQueryHeader', () => {
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
isMonitoringAccount={false}
/>
);
@@ -67,6 +68,7 @@ describe('MetricsQueryHeader', () => {
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
isMonitoringAccount={false}
/>
);
@@ -94,6 +96,7 @@ describe('MetricsQueryHeader', () => {
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
isMonitoringAccount={false}
/>
);
@@ -122,6 +125,7 @@ describe('MetricsQueryHeader', () => {
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
isMonitoringAccount={false}
/>
);

View File

@@ -2,7 +2,8 @@ import React, { useCallback, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { FlexItem, InlineSelect } from '@grafana/experimental';
import { Button, ConfirmModal, RadioButtonGroup } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { Badge, Button, ConfirmModal, RadioButtonGroup } from '@grafana/ui';
import { CloudWatchDatasource } from '../../datasource';
import { CloudWatchMetricsQuery, CloudWatchQuery, MetricEditorMode, MetricQueryType } from '../../types';
@@ -13,6 +14,7 @@ interface MetricsQueryHeaderProps {
onChange: (query: CloudWatchQuery) => void;
onRunQuery: () => void;
sqlCodeEditorIsDirty: boolean;
isMonitoringAccount: boolean;
}
const metricEditorModes: Array<SelectableValue<MetricQueryType>> = [
@@ -30,6 +32,7 @@ const MetricsQueryHeader: React.FC<MetricsQueryHeaderProps> = ({
sqlCodeEditorIsDirty,
onChange,
onRunQuery,
isMonitoringAccount,
}) => {
const { metricEditorMode, metricQueryType } = query;
const [showConfirm, setShowConfirm] = useState(false);
@@ -49,6 +52,11 @@ const MetricsQueryHeader: React.FC<MetricsQueryHeaderProps> = ({
[setShowConfirm, onChange, sqlCodeEditorIsDirty, query, metricEditorMode, metricQueryType]
);
const shouldDisplayMonitoringBadge =
query.metricQueryType === MetricQueryType.Search &&
isMonitoringAccount &&
config.featureToggles.cloudWatchCrossAccountQuerying;
return (
<>
<InlineSelect
@@ -61,6 +69,14 @@ const MetricsQueryHeader: React.FC<MetricsQueryHeaderProps> = ({
/>
<FlexItem grow={1} />
{shouldDisplayMonitoringBadge && (
<Badge
text="Monitoring account"
color="blue"
tooltip="AWS monitoring accounts view data from source accounts so you can centralize monitoring and troubleshoot activites"
></Badge>
)}
<RadioButtonGroup options={editorModes} size="sm" value={metricEditorMode} onChange={onEditorModeChange} />
{query.metricQueryType === MetricQueryType.Query && query.metricEditorMode === MetricEditorMode.Code && (

View File

@@ -2,8 +2,16 @@ import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { QueryEditorProps } from '@grafana/data';
import { config } from '@grafana/runtime';
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
import {
validLogsQuery,
validMetricQueryBuilderQuery,
validMetricQueryCodeQuery,
validMetricSearchBuilderQuery,
validMetricSearchCodeQuery,
} from '../__mocks__/queries';
import { CloudWatchDatasource } from '../datasource';
import { CloudWatchQuery, CloudWatchJsonData, MetricEditorMode, MetricQueryType } from '../types';
@@ -130,4 +138,82 @@ describe('PanelQueryEditor should render right editor', () => {
expect(screen.getByText('Metric name')).toBeInTheDocument();
});
});
interface MonitoringBadgeScenario {
name: string;
query: CloudWatchQuery;
toggle: boolean;
}
describe('monitoring badge', () => {
let originalValue: boolean | undefined;
let datasourceMock: ReturnType<typeof setupMockedDataSource>;
beforeEach(() => {
datasourceMock = setupMockedDataSource();
datasourceMock.datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(true);
datasourceMock.datasource.api.getMetrics = jest.fn().mockResolvedValue([]);
datasourceMock.datasource.api.getDimensionKeys = jest.fn().mockResolvedValue([]);
originalValue = config.featureToggles.cloudWatchCrossAccountQuerying;
});
afterEach(() => {
config.featureToggles.cloudWatchCrossAccountQuerying = originalValue;
});
describe('should be displayed when a monitoring account is returned and', () => {
const cases: MonitoringBadgeScenario[] = [
{ name: 'it is logs query and feature is enabled', query: validLogsQuery, toggle: true },
{
name: 'it is metric search builder query and feature is enabled',
query: validMetricSearchBuilderQuery,
toggle: true,
},
{
name: 'it is metric search code query and feature is enabled',
query: validMetricSearchCodeQuery,
toggle: true,
},
];
test.each(cases)('$name', async ({ query, toggle }) => {
config.featureToggles.cloudWatchCrossAccountQuerying = toggle;
await act(async () => {
render(<PanelQueryEditor {...props} datasource={datasourceMock.datasource} query={query} />);
});
expect(await screen.getByText('Monitoring account')).toBeInTheDocument();
});
});
describe('should not be displayed when a monitoring account is returned and', () => {
const cases: MonitoringBadgeScenario[] = [
{
name: 'it is metric query builder query and toggle is enabled',
query: validMetricQueryBuilderQuery,
toggle: true,
},
{
name: 'it is metric query code query and toggle is not enabled',
query: validMetricQueryCodeQuery,
toggle: true,
},
{ name: 'it is logs query and feature is not enabled', query: validLogsQuery, toggle: false },
{
name: 'it is metric search builder query and feature is not enabled',
query: validMetricSearchBuilderQuery,
toggle: false,
},
{
name: 'it is metric search code query and feature is not enabled',
query: validMetricSearchCodeQuery,
toggle: false,
},
];
test.each(cases)('$name', async ({ query, toggle }) => {
config.featureToggles.cloudWatchCrossAccountQuerying = toggle;
await act(async () => {
render(<PanelQueryEditor {...props} datasource={datasourceMock.datasource} query={query} />);
});
expect(await screen.queryByText('Monitoring account')).toBeNull();
});
});
});
});

View File

@@ -1,17 +1,25 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import selectEvent from 'react-select-event';
import { config } from '@grafana/runtime';
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
import { validLogsQuery, validMetricSearchBuilderQuery } from '../__mocks__/queries';
import { CloudWatchLogsQuery, CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from '../types';
import QueryHeader from './QueryHeader';
const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying;
const ds = setupMockedDataSource({
variables: [],
});
ds.datasource.api.getRegions = jest.fn().mockResolvedValue([]);
describe('QueryHeader', () => {
afterEach(() => {
config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue;
});
it('should display metric options for metrics', async () => {
const query: CloudWatchMetricsQuery = {
queryMode: 'Metrics',
@@ -80,4 +88,102 @@ describe('QueryHeader', () => {
expect(screen.queryByLabelText('Code')).toBeNull();
});
});
describe('when changing region', () => {
const { datasource } = setupMockedDataSource();
datasource.api.getRegions = jest.fn().mockResolvedValue([
{ value: 'us-east-2', label: 'us-east-2' },
{ value: 'us-east-1', label: 'us-east-1' },
]);
it('should reset account id if new region is not monitoring account', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = true;
const onChange = jest.fn();
datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(false);
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
datasource={datasource}
query={{ ...validMetricSearchBuilderQuery, region: 'us-east-1', accountId: 'all' }}
onChange={onChange}
onRunQuery={jest.fn()}
/>
);
await waitFor(() => expect(screen.queryByText('us-east-1')).toBeInTheDocument());
await act(async () => {
await selectEvent.select(screen.getByLabelText(/Region/), 'us-east-2', { container: document.body });
});
expect(onChange).toHaveBeenCalledWith({
...validMetricSearchBuilderQuery,
region: 'us-east-2',
accountId: undefined,
});
});
it('should not reset account id if new region is a monitoring account', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = true;
const onChange = jest.fn();
datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(true);
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
datasource={datasource}
query={{ ...validMetricSearchBuilderQuery, region: 'us-east-1', accountId: '123' }}
onChange={onChange}
onRunQuery={jest.fn()}
/>
);
await waitFor(() => expect(screen.queryByText('us-east-1')).toBeInTheDocument());
await act(async () => {
await selectEvent.select(screen.getByLabelText(/Region/), 'us-east-2', { container: document.body });
});
expect(onChange).toHaveBeenCalledWith({
...validMetricSearchBuilderQuery,
region: 'us-east-2',
accountId: '123',
});
});
it('should not call isMonitoringAccount if its a logs query', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = true;
const onChange = jest.fn();
datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(true);
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
datasource={datasource}
query={{ ...validLogsQuery, region: 'us-east-1' }}
onChange={onChange}
onRunQuery={jest.fn()}
/>
);
await waitFor(() => expect(screen.queryByText('us-east-1')).toBeInTheDocument());
await act(async () => {
await selectEvent.select(screen.getByLabelText(/Region/), 'us-east-2', { container: document.body });
});
expect(datasource.api.isMonitoringAccount).not.toHaveBeenCalledWith('us-east-2');
});
it('should not call isMonitoringAccount if feature toggle is not enabled', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = false;
const onChange = jest.fn();
datasource.api.isMonitoringAccount = jest.fn();
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
datasource={datasource}
query={{ ...validLogsQuery, region: 'us-east-1' }}
onChange={onChange}
onRunQuery={jest.fn()}
/>
);
await waitFor(() => expect(screen.queryByText('us-east-1')).toBeInTheDocument());
await act(async () => {
await selectEvent.select(screen.getByLabelText(/Region/), 'us-east-2', { container: document.body });
});
expect(datasource.api.isMonitoringAccount).not.toHaveBeenCalledWith();
});
});
});

View File

@@ -2,10 +2,13 @@ import { pick } from 'lodash';
import React from 'react';
import { SelectableValue, ExploreMode } from '@grafana/data';
import { EditorHeader, InlineSelect } from '@grafana/experimental';
import { EditorHeader, InlineSelect, FlexItem } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Badge } from '@grafana/ui';
import { CloudWatchDatasource } from '../datasource';
import { useRegions } from '../hooks';
import { isCloudWatchMetricsQuery } from '../guards';
import { useIsMonitoringAccount, useRegions } from '../hooks';
import { CloudWatchQuery, CloudWatchQueryMode } from '../types';
import MetricsQueryHeader from './MetricsQueryEditor/MetricsQueryHeader';
@@ -16,7 +19,6 @@ interface QueryHeaderProps {
onChange: (query: CloudWatchQuery) => void;
onRunQuery: () => void;
sqlCodeEditorIsDirty: boolean;
onRegionChange?: (region: string) => Promise<void>;
}
const apiModes: Array<SelectableValue<CloudWatchQueryMode>> = [
@@ -26,6 +28,7 @@ const apiModes: Array<SelectableValue<CloudWatchQueryMode>> = [
const QueryHeader: React.FC<QueryHeaderProps> = ({ query, sqlCodeEditorIsDirty, datasource, onChange, onRunQuery }) => {
const { queryMode, region } = query;
const isMonitoringAccount = useIsMonitoringAccount(datasource.api, query.region);
const [regions, regionIsLoading] = useRegions(datasource);
@@ -38,14 +41,18 @@ const QueryHeader: React.FC<QueryHeaderProps> = ({ query, sqlCodeEditorIsDirty,
} as CloudWatchQuery);
}
};
const onRegion = async ({ value }: SelectableValue<string>) => {
onChange({
...query,
region: value,
} as CloudWatchQuery);
const onRegionChange = async (region: string) => {
if (config.featureToggles.cloudWatchCrossAccountQuerying && isCloudWatchMetricsQuery(query)) {
const isMonitoringAccount = await datasource.api.isMonitoringAccount(region);
onChange({ ...query, region, accountId: isMonitoringAccount ? query.accountId : undefined });
} else {
onChange({ ...query, region });
}
};
const shouldDisplayMonitoringBadge =
queryMode === 'Logs' && isMonitoringAccount && config.featureToggles.cloudWatchCrossAccountQuerying;
return (
<EditorHeader>
<InlineSelect
@@ -53,19 +60,31 @@ const QueryHeader: React.FC<QueryHeaderProps> = ({ query, sqlCodeEditorIsDirty,
value={region}
placeholder="Select region"
allowCustomValue
onChange={({ value: region }) => region && onRegion({ value: region })}
onChange={({ value: region }) => region && onRegionChange(region)}
options={regions}
isLoading={regionIsLoading}
/>
<InlineSelect aria-label="Query mode" value={queryMode} options={apiModes} onChange={onQueryModeChange} />
{shouldDisplayMonitoringBadge && (
<>
<FlexItem grow={1} />
<Badge
text="Monitoring account"
color="blue"
tooltip="AWS monitoring accounts view data from source accounts so you can centralize monitoring and troubleshoot activites"
></Badge>
</>
)}
{queryMode === ExploreMode.Metrics && (
<MetricsQueryHeader
query={query}
datasource={datasource}
onChange={onChange}
onRunQuery={onRunQuery}
isMonitoringAccount={isMonitoringAccount}
sqlCodeEditorIsDirty={sqlCodeEditorIsDirty}
/>
)}

View File

@@ -8,7 +8,7 @@ import { CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType, SQLExpressio
const { datasource } = setupMockedDataSource();
const makeSQLQuery = (sql?: SQLExpression): CloudWatchMetricsQuery => ({
export const makeSQLQuery = (sql?: SQLExpression): CloudWatchMetricsQuery => ({
queryMode: 'Metrics',
refId: '',
id: '',

View File

@@ -48,7 +48,7 @@ const SQLBuilderSelectRow: React.FC<SQLBuilderSelectRowProps> = ({ datasource, q
const withSchemaEnabled = isUsingWithSchema(sql.from);
const namespaceOptions = useNamespaces(datasource);
const metricOptions = useMetrics(datasource, query.region, namespace);
const metricOptions = useMetrics(datasource, { region: query.region, namespace });
const existingFilters = useMemo(() => stringArrayToDimensions(schemaLabels ?? []), [schemaLabels]);
const unusedDimensionKeys = useDimensionKeys(datasource, {
region: query.region,

View File

@@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// eslint-disable-next-line lodash/import-scope
import lodash from 'lodash';
import React from 'react';
import Search from '../Search';
const defaultProps = {
searchPhrase: '',
searchFn: jest.fn(),
};
const originalDebounce = lodash.debounce;
describe('Search', () => {
beforeEach(() => {
lodash.debounce = jest.fn().mockImplementation((fn) => {
fn.cancel = () => {};
return fn;
});
});
afterEach(() => {
lodash.debounce = originalDebounce;
});
it('displays the search phrase passed in if it exists', async () => {
render(<Search {...defaultProps} searchPhrase={'testPhrase'} />);
expect(await screen.findByDisplayValue('testPhrase')).toBeInTheDocument();
});
it('displays placeholder text if search phrase is not passed in', async () => {
render(<Search {...defaultProps} />);
expect(await screen.findByPlaceholderText('search by log group name prefix')).toBeInTheDocument();
});
it('calls a debounced version of searchFn when typed in', async () => {
const searchFn = jest.fn();
render(<Search {...defaultProps} searchFn={searchFn} />);
await userEvent.type(await screen.findByLabelText('log group search'), 'something');
expect(searchFn).toBeCalledWith('s');
expect(searchFn).toHaveBeenLastCalledWith('something');
});
});

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { InlineField } from '@grafana/ui';
import { Dimensions } from '..';
@@ -26,6 +27,9 @@ const queryTypes: Array<{ value: string; label: string }> = [
{ value: VariableQueryType.ResourceArns, label: 'Resource ARNs' },
{ value: VariableQueryType.Statistics, label: 'Statistics' },
{ value: VariableQueryType.LogGroups, label: 'Log Groups' },
...(config.featureToggles.cloudWatchCrossAccountQuerying
? [{ value: VariableQueryType.Accounts, label: 'Accounts' }]
: []),
];
export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
@@ -34,7 +38,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
const { region, namespace, metricName, dimensionKey, dimensionFilters } = parsedQuery;
const [regions, regionIsLoading] = useRegions(datasource);
const namespaces = useNamespaces(datasource);
const metrics = useMetrics(datasource, region, namespace);
const metrics = useMetrics(datasource, { region, namespace });
const dimensionKeys = useDimensionKeys(datasource, { region, namespace, metricName });
const keysForDimensionFilter = useDimensionKeys(datasource, { region, namespace, metricName, dimensionFilters });
@@ -90,6 +94,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
VariableQueryType.EC2InstanceAttributes,
VariableQueryType.ResourceArns,
VariableQueryType.LogGroups,
VariableQueryType.Accounts,
].includes(parsedQuery.queryType);
const hasNamespaceField = [
VariableQueryType.Metrics,

View File

@@ -0,0 +1,80 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
const getStyles = (theme: GrafanaTheme2) => ({
table: css({
width: '100%',
tableLayout: 'fixed',
}),
tableScroller: css({
maxHeight: '50vh',
overflow: 'auto',
}),
row: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
'&:last-of-type': {
borderBottomColor: theme.colors.border.medium,
},
}),
cell: css({
padding: theme.spacing(1, 1, 1, 0),
width: '25%',
'&:first-of-type': {
width: '50%',
padding: theme.spacing(1, 1, 1, 2),
},
}),
logGroupSearchResults: css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: '90%',
verticalAlign: 'middle',
}),
modal: css({
width: theme.breakpoints.values.lg,
}),
selectAccountId: css({
maxWidth: '100px',
}),
logGroupSelectionArea: css({
display: 'flex',
}),
resultLimit: css({
margin: '4px 0',
fontStyle: 'italic',
}),
selectedLogGroup: css({
background: theme.colors.background.secondary,
borderRadius: theme.shape.borderRadius(),
margin: theme.spacing(0.25, 1, 0.25, 0),
padding: theme.spacing(0.25, 0, 0.25, 1),
color: theme.colors.text.primary,
fontSize: theme.typography.size.sm,
}),
search: css({
marginRight: '10px',
}),
removeButton: css({
verticalAlign: 'middle',
}),
addBtn: css({
marginRight: '10px',
}),
});
export default getStyles;

View File

@@ -10,7 +10,7 @@ import {
regionVariable,
} from './__mocks__/CloudWatchDataSource';
import { setupForLogs } from './__mocks__/logsTestContext';
import { validLogsQuery, validMetricsQuery } from './__mocks__/queries';
import { validLogsQuery, validMetricSearchBuilderQuery } from './__mocks__/queries';
import { timeRange } from './__mocks__/timeRange';
import { CloudWatchLogsQuery, CloudWatchMetricsQuery, CloudWatchQuery } from './types';
@@ -62,9 +62,9 @@ describe('datasource', () => {
const testTable: Array<{ query: CloudWatchQuery; valid: boolean }> = [
{ query: { ...validLogsQuery, hide: true }, valid: false },
{ query: { ...validLogsQuery, hide: false }, valid: true },
{ query: { ...validMetricsQuery, hide: true }, valid: false },
{ query: { ...validMetricsQuery, hide: true, id: 'queryA' }, valid: true },
{ query: { ...validMetricsQuery, hide: false }, valid: true },
{ query: { ...validMetricSearchBuilderQuery, hide: true }, valid: false },
{ query: { ...validMetricSearchBuilderQuery, hide: true, id: 'queryA' }, valid: true },
{ query: { ...validMetricSearchBuilderQuery, hide: false }, valid: true },
];
test.each(testTable)('should filter out hidden queries unless id is provided', ({ query, valid }) => {
@@ -205,13 +205,9 @@ describe('datasource', () => {
it('should map resource response to metric response', async () => {
const datasource = setupMockedDataSource({
getMock: jest.fn().mockResolvedValue([
{ value: { namespace: 'AWS/EC2', name: 'CPUUtilization' } },
{
namespace: 'AWS/EC2',
name: 'CPUUtilization',
},
{
namespace: 'AWS/Redshift',
name: 'CPUPercentage',
value: { namespace: 'AWS/Redshift', name: 'CPUPercentage' },
},
]),
}).datasource;

View File

@@ -53,7 +53,7 @@ export class CloudWatchDatasource
constructor(
instanceSettings: DataSourceInstanceSettings<CloudWatchJsonData>,
private readonly templateSrv: TemplateSrv = getTemplateSrv(),
readonly templateSrv: TemplateSrv = getTemplateSrv(),
timeSrv: TimeSrv = getTimeSrv()
) {
super(instanceSettings);
@@ -164,7 +164,7 @@ export class CloudWatchDatasource
// public
getVariables() {
return this.templateSrv.getVariables().map((v) => `$${v.name}`);
return this.api.getVariables();
}
getActualRegion(region?: string) {

View File

@@ -1,5 +1,7 @@
import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api';
import { config } from '@grafana/runtime';
// Dynamic labels: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/graph-dynamic-labels.html
export const DYNAMIC_LABEL_PATTERNS = [
'${DATAPOINT_COUNT}',
@@ -27,6 +29,7 @@ export const DYNAMIC_LABEL_PATTERNS = [
"${PROP('Region')}",
"${PROP('Stat')}",
'${SUM}',
...(config.featureToggles.cloudWatchCrossAccountQuerying ? ["${PROP('AccountLabel')}"] : []),
];
export const language: monacoType.languages.IMonarchLanguage = {

View File

@@ -0,0 +1,141 @@
import { renderHook } from '@testing-library/react-hooks';
import { config } from '@grafana/runtime';
import { setupMockedAPI } from './__mocks__/API';
import {
accountIdVariable,
dimensionVariable,
metricVariable,
namespaceVariable,
regionVariable,
setupMockedDataSource,
} from './__mocks__/CloudWatchDataSource';
import { useAccountOptions, useDimensionKeys, useIsMonitoringAccount, useMetrics } from './hooks';
const WAIT_OPTIONS = {
timeout: 1000,
};
const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying;
describe('hooks', () => {
afterEach(() => {
config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue;
});
describe('useIsMonitoringAccount', () => {
it('should interpolate variables before calling api', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = true;
const { api } = setupMockedAPI({
variables: [regionVariable],
});
const isMonitoringAccountMock = jest.fn().mockResolvedValue(true);
api.isMonitoringAccount = isMonitoringAccountMock;
const { waitForNextUpdate } = renderHook(() => useIsMonitoringAccount(api, `$${regionVariable.name}`));
await waitForNextUpdate(WAIT_OPTIONS);
expect(isMonitoringAccountMock).toHaveBeenCalledTimes(1);
expect(isMonitoringAccountMock).toHaveBeenCalledWith(regionVariable.current.value);
});
});
describe('useMetricNames', () => {
it('should interpolate variables before calling api', async () => {
const { datasource } = setupMockedDataSource({
variables: [regionVariable, namespaceVariable, accountIdVariable],
});
const getMetricsMock = jest.fn().mockResolvedValue([]);
datasource.api.getMetrics = getMetricsMock;
const { waitForNextUpdate } = renderHook(() =>
useMetrics(datasource, {
namespace: `$${namespaceVariable.name}`,
region: `$${regionVariable.name}`,
accountId: `$${accountIdVariable.name}`,
})
);
await waitForNextUpdate(WAIT_OPTIONS);
expect(getMetricsMock).toHaveBeenCalledTimes(1);
expect(getMetricsMock).toHaveBeenCalledWith({
region: regionVariable.current.value,
namespace: namespaceVariable.current.value,
accountId: accountIdVariable.current.value,
});
});
});
describe('useDimensionKeys', () => {
it('should interpolate variables before calling api', async () => {
const { datasource } = setupMockedDataSource({
mockGetVariableName: true,
variables: [regionVariable, namespaceVariable, accountIdVariable, metricVariable, dimensionVariable],
});
const getDimensionKeysMock = jest.fn().mockResolvedValue([]);
datasource.api.getDimensionKeys = getDimensionKeysMock;
const { waitForNextUpdate } = renderHook(() =>
useDimensionKeys(datasource, {
namespace: `$${namespaceVariable.name}`,
metricName: `$${metricVariable.name}`,
region: `$${regionVariable.name}`,
accountId: `$${accountIdVariable.name}`,
dimensionFilters: {
environment: `$${dimensionVariable.name}`,
},
})
);
await waitForNextUpdate(WAIT_OPTIONS);
expect(getDimensionKeysMock).toHaveBeenCalledTimes(1);
expect(getDimensionKeysMock).toHaveBeenCalledWith({
region: regionVariable.current.value,
namespace: namespaceVariable.current.value,
metricName: metricVariable.current.value,
accountId: accountIdVariable.current.value,
dimensionFilters: {
environment: [dimensionVariable.current.value],
},
});
});
});
describe('useAccountOptions', () => {
it('does not call the api if the feature toggle is off', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = false;
const { api } = setupMockedAPI({
variables: [regionVariable],
});
const getAccountsMock = jest.fn().mockResolvedValue([{ id: '123', label: 'accountLabel' }]);
api.getAccounts = getAccountsMock;
const { waitForNextUpdate } = renderHook(() => useAccountOptions(api, `$${regionVariable.name}`));
await waitForNextUpdate(WAIT_OPTIONS);
expect(getAccountsMock).toHaveBeenCalledTimes(0);
});
it('interpolates region variables before calling the api', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = true;
const { api } = setupMockedAPI({
variables: [regionVariable],
});
const getAccountsMock = jest.fn().mockResolvedValue([{ id: '123', label: 'accountLabel' }]);
api.getAccounts = getAccountsMock;
const { waitForNextUpdate } = renderHook(() => useAccountOptions(api, `$${regionVariable.name}`));
await waitForNextUpdate(WAIT_OPTIONS);
expect(getAccountsMock).toHaveBeenCalledTimes(1);
expect(getAccountsMock).toHaveBeenCalledWith({ region: regionVariable.current.value });
});
it('returns properly formatted account options, and template variables', async () => {
config.featureToggles.cloudWatchCrossAccountQuerying = true;
const { api } = setupMockedAPI({
variables: [regionVariable],
});
const getAccountsMock = jest.fn().mockResolvedValue([{ id: '123', label: 'accountLabel' }]);
api.getAccounts = getAccountsMock;
const { waitForNextUpdate, result } = renderHook(() => useAccountOptions(api, `$${regionVariable.name}`));
await waitForNextUpdate(WAIT_OPTIONS);
expect(result.current.value).toEqual([
{ label: 'accountLabel', description: '123', value: '123' },
{ label: 'Template Variables', options: [{ label: '$region', value: '$region' }] },
]);
});
});
});

View File

@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react';
import { useDeepCompareEffect } from 'react-use';
import { useAsyncFn, useDeepCompareEffect } from 'react-use';
import { SelectableValue, toOption } from '@grafana/data';
import { config } from '@grafana/runtime';
import { CloudWatchAPI } from './api';
import { CloudWatchDatasource } from './datasource';
import { GetDimensionKeysRequest } from './types';
import { GetDimensionKeysRequest, GetMetricsRequest } from './types';
import { appendTemplateVariables } from './utils/utils';
export const useRegions = (datasource: CloudWatchDatasource): [Array<SelectableValue<string>>, boolean] => {
@@ -39,31 +41,123 @@ export const useNamespaces = (datasource: CloudWatchDatasource) => {
return namespaces;
};
export const useMetrics = (datasource: CloudWatchDatasource, region: string, namespace: string | undefined) => {
export const useMetrics = (datasource: CloudWatchDatasource, { region, namespace, accountId }: GetMetricsRequest) => {
const [metrics, setMetrics] = useState<Array<SelectableValue<string>>>([]);
// need to ensure dependency array below recieves the interpolated value so that the effect is triggered when a variable is changed
if (region) {
region = datasource.templateSrv.replace(region, {});
}
if (namespace) {
namespace = datasource.templateSrv.replace(namespace, {});
}
if (accountId) {
accountId = datasource.templateSrv.replace(accountId, {});
}
useEffect(() => {
datasource.api.getMetrics({ namespace, region }).then((result: Array<SelectableValue<string>>) => {
datasource.api.getMetrics({ namespace, region, accountId }).then((result: Array<SelectableValue<string>>) => {
setMetrics(appendTemplateVariables(datasource, result));
});
}, [datasource, region, namespace]);
}, [datasource, region, namespace, accountId]);
return metrics;
};
export const useDimensionKeys = (
datasource: CloudWatchDatasource,
{ namespace, region, dimensionFilters, metricName }: GetDimensionKeysRequest
{ region, namespace, metricName, dimensionFilters, accountId }: GetDimensionKeysRequest
) => {
const [dimensionKeys, setDimensionKeys] = useState<Array<SelectableValue<string>>>([]);
// need to ensure dependency array below revieves the interpolated value so that the effect is triggered when a variable is changed
if (region) {
region = datasource.templateSrv.replace(region, {});
}
if (namespace) {
namespace = datasource.templateSrv.replace(namespace, {});
}
if (metricName) {
metricName = datasource.templateSrv.replace(metricName, {});
}
if (accountId) {
accountId = datasource.templateSrv.replace(accountId, {});
}
if (dimensionFilters) {
dimensionFilters = datasource.api.convertDimensionFormat(dimensionFilters, {});
}
// doing deep comparison to avoid making new api calls to list metrics unless dimension filter object props changes
useDeepCompareEffect(() => {
datasource.api
.getDimensionKeys({ namespace, region, dimensionFilters, metricName })
.getDimensionKeys({ namespace, region, metricName, accountId, dimensionFilters })
.then((result: Array<SelectableValue<string>>) => {
setDimensionKeys(appendTemplateVariables(datasource, result));
});
}, [datasource, region, namespace, metricName, dimensionFilters]);
}, [datasource, namespace, region, metricName, accountId, dimensionFilters]);
return dimensionKeys;
};
export const useIsMonitoringAccount = (api: CloudWatchAPI, region: string) => {
const [isMonitoringAccount, setIsMonitoringAccount] = useState(false);
// we call this before the use effect to ensure dependency array below
// receives the interpolated value so that the effect is triggered when a variable is changed
if (region) {
region = api.templateSrv.replace(region, {});
}
useEffect(() => {
if (config.featureToggles.cloudWatchCrossAccountQuerying) {
api.isMonitoringAccount(region).then((result) => setIsMonitoringAccount(result));
}
}, [region, api]);
return isMonitoringAccount;
};
export const useAccountOptions = (
api: Pick<CloudWatchAPI, 'getAccounts' | 'templateSrv' | 'getVariables'>,
region: string
) => {
// we call this before the use effect to ensure dependency array below
// receives the interpolated value so that the effect is triggered when a variable is changed
if (region) {
region = api.templateSrv.replace(region, {});
}
const fetchAccountOptions = async () => {
if (!config.featureToggles.cloudWatchCrossAccountQuerying) {
return Promise.resolve([]);
}
const accounts = await api.getAccounts({ region });
if (accounts.length === 0) {
return [];
}
const options: Array<SelectableValue<string>> = accounts.map((a) => ({
label: a.label,
value: a.id,
description: a.id,
}));
const variableOptions = api.getVariables().map(toOption);
const variableOptionGroup: SelectableValue<string> = {
label: 'Template Variables',
options: variableOptions,
};
return [...options, variableOptionGroup];
};
const [state, doFetch] = useAsyncFn(fetchAccountOptions, [api, region]);
useEffect(() => {
doFetch();
}, [api, region, doFetch]);
return state;
};

View File

@@ -1,4 +1,4 @@
import { isEmpty, set } from 'lodash';
import { set } from 'lodash';
import {
Observable,
of,
@@ -89,6 +89,7 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
queryString: target.expression || '',
refId: target.refId,
logGroupNames: target.logGroupNames || this.defaultLogGroups,
logGroups: target.logGroups || [], //todo handle defaults
region: super.replaceVariableAndDisplayWarningIfMulti(
this.getActualRegion(target.region),
options.scopedVars,
@@ -97,14 +98,14 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
),
}));
const validLogQueries = queryParams.filter((item) => item.logGroupNames?.length);
if (logQueries.length > validLogQueries.length) {
return of({ data: [], error: { message: 'Log group is required' } });
}
const hasQueryWithMissingLogGroupSelection = queryParams.some((qp) => {
const missingLogGroupNames = qp.logGroupNames.length === 0;
const missingLogGroups = qp.logGroups.length === 0;
return missingLogGroupNames && missingLogGroups;
});
// No valid targets, return the empty result to save a round trip.
if (isEmpty(validLogQueries)) {
return of({ data: [], state: LoadingState.Done });
if (hasQueryWithMissingLogGroupSelection) {
return of({ data: [], error: { message: 'Log group is required' } });
}
const startTime = new Date();

View File

@@ -13,8 +13,10 @@ import {
limitVariable,
dimensionVariable,
periodIntervalVariable,
accountIdVariable,
} from '../__mocks__/CloudWatchDataSource';
import { setupMockedMetricsQueryRunner } from '../__mocks__/MetricsQueryRunner';
import { validMetricSearchBuilderQuery } from '../__mocks__/queries';
import { MetricQueryType, MetricEditorMode, CloudWatchMetricsQuery, DataQueryError } from '../types';
describe('CloudWatchMetricsQueryRunner', () => {
@@ -339,6 +341,24 @@ describe('CloudWatchMetricsQueryRunner', () => {
});
describe('template variable interpolation', () => {
it('replaceMetricQueryVars interpolates account id if its part of the query', async () => {
const { runner } = setupMockedMetricsQueryRunner({
variables: [accountIdVariable],
});
const result = runner.replaceMetricQueryVars({ ...validMetricSearchBuilderQuery, accountId: '$accountId' }, {});
expect(result.accountId).toBe(accountIdVariable.current.value);
});
it('replaceMetricQueryVars should not change account id if its not part of the query', async () => {
const { runner } = setupMockedMetricsQueryRunner({
variables: [accountIdVariable],
});
const result = runner.replaceMetricQueryVars({ ...validMetricSearchBuilderQuery, accountId: undefined }, {});
expect(result.accountId).toBeUndefined();
});
it('interpolates variables correctly', async () => {
const { runner, fetchMock, request } = setupMockedMetricsQueryRunner({
variables: [namespaceVariable, metricVariable, labelsVariable, limitVariable],

View File

@@ -70,7 +70,7 @@ export class CloudWatchMetricsQueryRunner extends CloudWatchRequest {
.filter(this.filterMetricQuery)
.map((q: CloudWatchMetricsQuery): MetricQuery => {
const migratedQuery = migrateMetricQuery(q);
const migratedAndIterpolatedQuery = this.replaceMetricQueryVars(migratedQuery, options);
const migratedAndIterpolatedQuery = this.replaceMetricQueryVars(migratedQuery, options.scopedVars);
return {
timezoneUTCOffset,
@@ -174,35 +174,25 @@ export class CloudWatchMetricsQueryRunner extends CloudWatchRequest {
return filterMetricsQuery(query);
}
replaceMetricQueryVars(
query: CloudWatchMetricsQuery,
options: DataQueryRequest<CloudWatchQuery>
): CloudWatchMetricsQuery {
query.region = this.templateSrv.replace(this.getActualRegion(query.region), options.scopedVars);
query.namespace = this.replaceVariableAndDisplayWarningIfMulti(
query.namespace,
options.scopedVars,
true,
'namespace'
);
query.metricName = this.replaceVariableAndDisplayWarningIfMulti(
query.metricName,
options.scopedVars,
true,
'metric name'
);
query.dimensions = this.convertDimensionFormat(query.dimensions ?? {}, options.scopedVars);
query.statistic = this.templateSrv.replace(query.statistic, options.scopedVars);
query.period = String(this.getPeriod(query, options)); // use string format for period in graph query, and alerting
query.id = this.templateSrv.replace(query.id, options.scopedVars);
query.expression = this.templateSrv.replace(query.expression, options.scopedVars);
query.sqlExpression = this.templateSrv.replace(query.sqlExpression, options.scopedVars, 'raw');
replaceMetricQueryVars(query: CloudWatchMetricsQuery, scopedVars: ScopedVars): CloudWatchMetricsQuery {
query.region = this.templateSrv.replace(this.getActualRegion(query.region), scopedVars);
query.namespace = this.replaceVariableAndDisplayWarningIfMulti(query.namespace, scopedVars, true, 'namespace');
query.metricName = this.replaceVariableAndDisplayWarningIfMulti(query.metricName, scopedVars, true, 'metric name');
query.dimensions = this.convertDimensionFormat(query.dimensions ?? {}, scopedVars);
query.statistic = this.templateSrv.replace(query.statistic, scopedVars);
query.period = String(this.getPeriod(query, scopedVars)); // use string format for period in graph query, and alerting
query.id = this.templateSrv.replace(query.id, scopedVars);
query.expression = this.templateSrv.replace(query.expression, scopedVars);
query.sqlExpression = this.templateSrv.replace(query.sqlExpression, scopedVars, 'raw');
if (query.accountId) {
query.accountId = this.templateSrv.replace(query.accountId, scopedVars);
}
return query;
}
getPeriod(target: CloudWatchMetricsQuery, options: DataQueryRequest<CloudWatchQuery>) {
let period = this.templateSrv.replace(target.period, options.scopedVars);
getPeriod(target: CloudWatchMetricsQuery, scopedVars: ScopedVars) {
let period = this.templateSrv.replace(target.period, scopedVars);
if (period && period.toLowerCase() !== 'auto') {
let p: number;
if (/^\d+$/.test(period)) {

View File

@@ -116,6 +116,10 @@ export abstract class CloudWatchRequest {
}
return region;
}
getVariables() {
return this.templateSrv.getVariables().map((v) => `$${v.name}`);
}
}
const displayCustomError = (title: string, message: string) =>

View File

@@ -1,6 +1,6 @@
import { DashboardLoadedEvent } from '@grafana/data';
let handler: (e: DashboardLoadedEvent<CloudWatchQuery>) => {};
import { reportInteraction } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import './module';
import { CloudWatchDashboardLoadedEvent } from './__mocks__/dashboardOnLoadedEvent';
@@ -18,22 +18,26 @@ jest.mock('@grafana/runtime', () => {
};
});
const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying;
describe('onDashboardLoadedHandler', () => {
it('should report a `grafana_ds_cloudwatch_dashboard_loaded` interaction ', () => {
config.featureToggles.cloudWatchCrossAccountQuerying = true;
handler(CloudWatchDashboardLoadedEvent);
expect(reportInteraction).toHaveBeenCalledWith('grafana_ds_cloudwatch_dashboard_loaded', {
dashboard_id: 'dashboard123',
grafana_version: 'v9.0.0',
org_id: 1,
logs_queries_count: 1,
metrics_queries_count: 20,
metrics_queries_count: 21,
metrics_query_builder_count: 3,
metrics_query_code_count: 4,
metrics_query_count: 7,
metrics_search_builder_count: 8,
metrics_search_builder_count: 9,
metrics_search_code_count: 5,
metrics_search_count: 13,
metrics_search_match_exact_count: 8,
metrics_search_count: 14,
metrics_search_match_exact_count: 9,
metrics_queries_with_account_count: 1,
});
config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue;
});
});

View File

@@ -1,5 +1,5 @@
import { DashboardLoadedEvent } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import { isCloudWatchLogsQuery, isCloudWatchMetricsQuery } from './guards';
import { migrateMetricQuery } from './migrations/metricQueryMigrations';
@@ -52,6 +52,9 @@ type CloudWatchOnDashboardLoadedTrackingEvent = {
/* The number of "Insights" queries that are using the code mode.
Should be measured in relation to metrics_query_count, e.g metrics_query_builder_count + metrics_query_code_count = metrics_query_count */
metrics_query_code_count: number;
/* The number of CloudWatch metrics queries that have specified an account in its cross account metric stat query */
metrics_queries_with_account_count: number;
};
export const onDashboardLoadedHandler = ({
@@ -93,6 +96,7 @@ export const onDashboardLoadedHandler = ({
metrics_query_count: 0,
metrics_query_builder_count: 0,
metrics_query_code_count: 0,
metrics_queries_with_account_count: 0,
};
for (const q of metricsQueries) {
@@ -109,6 +113,9 @@ export const onDashboardLoadedHandler = ({
e.metrics_query_code_count += +Boolean(
q.metricQueryType === MetricQueryType.Query && q.metricEditorMode === MetricEditorMode.Code
);
e.metrics_queries_with_account_count += +Boolean(
config.featureToggles.cloudWatchCrossAccountQuerying && isMetricSearchBuilder(q) && q.accountId
);
}
reportInteraction('grafana_ds_cloudwatch_dashboard_loaded', e);

View File

@@ -1,6 +1,7 @@
import { AwsAuthDataSourceJsonData, AwsAuthDataSourceSecureJsonData } from '@grafana/aws-sdk';
import { DataFrame, DataQuery, DataSourceRef, SelectableValue } from '@grafana/data';
import { SelectableResourceValue } from './api';
import {
QueryEditorArrayExpression,
QueryEditorFunctionExpression,
@@ -64,6 +65,7 @@ export interface MetricStat {
dimensions?: Dimensions;
matchExact?: boolean;
period?: string;
accountId?: string;
statistic?: string;
/**
* @deprecated use statistic
@@ -98,8 +100,10 @@ export interface CloudWatchLogsQuery extends DataQuery {
id: string;
region: string;
expression?: string;
logGroupNames?: string[];
statsGroups?: string[];
logGroups?: SelectableResourceValue[];
/* not quite deprecated yet, but will be soon */
logGroupNames?: string[];
}
export type CloudWatchQuery = CloudWatchMetricsQuery | CloudWatchLogsQuery | CloudWatchAnnotationQuery;
@@ -217,24 +221,6 @@ export interface GetQueryResultsResponse {
*/
status?: QueryStatus;
}
export interface DescribeLogGroupsRequest {
/**
* The prefix to match.
*/
logGroupNamePrefix?: string;
/**
* The token for the next set of items to return. (You received this token from a previous call.)
*/
nextToken?: string;
/**
* The maximum number of items returned. If you don't specify a value, the default is up to 50 items.
*/
limit?: number;
refId?: string;
region: string;
}
export interface TSDBResponse<T = any> {
results: Record<string, TSDBQueryResult<T>>;
message?: string;
@@ -390,6 +376,7 @@ export enum VariableQueryType {
ResourceArns = 'resourceARNs',
Statistics = 'statistics',
LogGroups = 'logGroups',
Accounts = 'accounts',
}
export interface OldVariableQuery extends DataQuery {
@@ -458,6 +445,7 @@ export interface MetricResponse {
export interface ResourceRequest {
region: string;
accountId?: string;
}
export interface GetDimensionKeysRequest extends ResourceRequest {
@@ -476,3 +464,33 @@ export interface GetDimensionValuesRequest extends ResourceRequest {
export interface GetMetricsRequest extends ResourceRequest {
namespace?: string;
}
export interface DescribeLogGroupsRequest extends ResourceRequest {
logGroupNamePrefix?: string;
logGroupPattern?: string;
// used by legacy requests, in the future deprecate these fields
refId?: string;
limit?: number;
}
export interface Account {
arn: string;
id: string;
label: string;
isMonitoringAccount: boolean;
}
export interface LogGroupResponse {
arn: string;
name: string;
}
export interface MetricResponse {
name: string;
namespace: string;
}
export interface ResourceResponse<T> {
accountId?: string;
value: T;
}

View File

@@ -1,5 +1,6 @@
import { toOption } from '@grafana/data';
import { setupMockedAPI } from './__mocks__/API';
import { dimensionVariable, labelsVariable, setupMockedDataSource } from './__mocks__/CloudWatchDataSource';
import { VariableQuery, VariableQueryType } from './types';
import { CloudWatchVariableSupport } from './variables';
@@ -22,6 +23,7 @@ mock.datasource.api.getNamespaces = jest.fn().mockResolvedValue([{ label: 'b', v
mock.datasource.api.getMetrics = jest.fn().mockResolvedValue([{ label: 'c', value: 'c' }]);
mock.datasource.api.getDimensionKeys = jest.fn().mockResolvedValue([{ label: 'd', value: 'd' }]);
mock.datasource.api.describeAllLogGroups = jest.fn().mockResolvedValue(['a', 'b'].map(toOption));
mock.datasource.api.getAccounts = jest.fn().mockResolvedValue([]);
const getDimensionValues = jest.fn().mockResolvedValue([{ label: 'e', value: 'e' }]);
const getEbsVolumeIds = jest.fn().mockResolvedValue([{ label: 'f', value: 'f' }]);
const getEc2InstanceAttribute = jest.fn().mockResolvedValue([{ label: 'g', value: 'g' }]);
@@ -50,6 +52,28 @@ describe('variables', () => {
expect(result).toEqual([{ text: 'd', value: 'd', expandable: true }]);
});
describe('accounts', () => {
it('should run accounts', async () => {
const { api } = setupMockedAPI();
const getAccountMock = jest.fn().mockResolvedValue([]);
api.getAccounts = getAccountMock;
const variables = new CloudWatchVariableSupport(api);
await variables.execute({ ...defaultQuery, queryType: VariableQueryType.Accounts });
expect(getAccountMock).toHaveBeenCalledWith({ region: defaultQuery.region });
});
it('should map accounts to metric find value and insert "all" option', async () => {
const { api } = setupMockedAPI();
api.getAccounts = jest.fn().mockResolvedValue([{ id: '123', label: 'Account1' }]);
const variables = new CloudWatchVariableSupport(api);
const result = await variables.execute({ ...defaultQuery, queryType: VariableQueryType.Accounts });
expect(result).toEqual([
{ text: 'All', value: 'all', expandable: true },
{ text: 'Account1', value: '123', expandable: true },
]);
});
});
describe('dimension values', () => {
const query = {
...defaultQuery,

View File

@@ -1,9 +1,16 @@
import { from, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { CustomVariableSupport, DataQueryRequest, DataQueryResponse } from '@grafana/data';
import {
CustomVariableSupport,
DataQueryRequest,
DataQueryResponse,
MetricFindValue,
SelectableValue,
} from '@grafana/data';
import { CloudWatchAPI } from './api';
import { ALL_ACCOUNTS_OPTION } from './components/Account';
import { VariableQueryEditor } from './components/VariableQueryEditor/VariableQueryEditor';
import { CloudWatchDatasource } from './datasource';
import { migrateVariableQuery } from './migrations/variableQueryMigrations';
@@ -46,101 +53,68 @@ export class CloudWatchVariableSupport extends CustomVariableSupport<CloudWatchD
return this.handleStatisticsQuery();
case VariableQueryType.LogGroups:
return this.handleLogGroupsQuery(query);
case VariableQueryType.Accounts:
return this.handleAccountsQuery(query);
}
} catch (error) {
console.error(`Could not run CloudWatchMetricFindQuery ${query}`, error);
return [];
}
}
async handleLogGroupsQuery({ region, logGroupPrefix }: VariableQuery) {
const logGroups = await this.api.describeAllLogGroups({
region,
logGroupNamePrefix: logGroupPrefix,
});
return logGroups.map((s) => ({
text: s.value,
value: s.value,
expandable: true,
}));
return this.api
.describeAllLogGroups({
region,
logGroupNamePrefix: logGroupPrefix,
})
.then((logGroups) => logGroups.map(selectableValueToMetricFindOption));
}
async handleRegionsQuery() {
const regions = await this.api.getRegions();
return regions.map((s) => ({
text: s.label,
value: s.value,
expandable: true,
}));
return this.api.getRegions().then((regions) => regions.map(selectableValueToMetricFindOption));
}
async handleNamespacesQuery() {
const namespaces = await this.api.getNamespaces();
return namespaces.map((s) => ({
text: s.label,
value: s.value,
expandable: true,
}));
return this.api.getNamespaces().then((namespaces) => namespaces.map(selectableValueToMetricFindOption));
}
async handleMetricsQuery({ namespace, region }: VariableQuery) {
const metrics = await this.api.getMetrics({ namespace, region });
return metrics.map((s) => ({
text: s.label,
value: s.value,
expandable: true,
}));
return this.api.getMetrics({ namespace, region }).then((metrics) => metrics.map(selectableValueToMetricFindOption));
}
async handleDimensionKeysQuery({ namespace, region }: VariableQuery) {
const keys = await this.api.getDimensionKeys({ namespace, region });
return keys.map((s) => ({
text: s.label,
value: s.value,
expandable: true,
}));
return this.api.getDimensionKeys({ namespace, region }).then((keys) => keys.map(selectableValueToMetricFindOption));
}
async handleDimensionValuesQuery({ namespace, region, dimensionKey, metricName, dimensionFilters }: VariableQuery) {
if (!dimensionKey || !metricName) {
return [];
}
const keys = await this.api.getDimensionValues({
region,
namespace,
metricName,
dimensionKey,
dimensionFilters,
});
return keys.map((s) => ({
text: s.label,
value: s.value,
expandable: true,
}));
return this.api
.getDimensionValues({
region,
namespace,
metricName,
dimensionKey,
dimensionFilters,
})
.then((values) => values.map(selectableValueToMetricFindOption));
}
async handleEbsVolumeIdsQuery({ region, instanceID }: VariableQuery) {
if (!instanceID) {
return [];
}
const ids = await this.api.getEbsVolumeIds(region, instanceID);
return ids.map((s) => ({
text: s.label,
value: s.value,
expandable: true,
}));
return this.api.getEbsVolumeIds(region, instanceID).then((ids) => ids.map(selectableValueToMetricFindOption));
}
async handleEc2InstanceAttributeQuery({ region, attributeName, ec2Filters }: VariableQuery) {
if (!attributeName) {
return [];
}
const values = await this.api.getEc2InstanceAttribute(region, attributeName, ec2Filters ?? {});
return values.map((s) => ({
text: s.label,
value: s.value,
expandable: true,
}));
return this.api
.getEc2InstanceAttribute(region, attributeName, ec2Filters ?? {})
.then((values) => values.map(selectableValueToMetricFindOption));
}
async handleResourceARNsQuery({ region, resourceType, tags }: VariableQuery) {
@@ -148,11 +122,7 @@ export class CloudWatchVariableSupport extends CustomVariableSupport<CloudWatchD
return [];
}
const keys = await this.api.getResourceARNs(region, resourceType, tags ?? {});
return keys.map((s) => ({
text: s.label,
value: s.value,
expandable: true,
}));
return keys.map(selectableValueToMetricFindOption);
}
async handleStatisticsQuery() {
@@ -162,4 +132,21 @@ export class CloudWatchVariableSupport extends CustomVariableSupport<CloudWatchD
expandable: true,
}));
}
allMetricFindValue: MetricFindValue = { text: 'All', value: ALL_ACCOUNTS_OPTION.value, expandable: true };
async handleAccountsQuery({ region }: VariableQuery) {
return this.api.getAccounts({ region }).then((accounts) => {
const metricFindOptions = accounts.map((account) => ({
text: account.label,
value: account.id,
expandable: true,
}));
return metricFindOptions.length ? [this.allMetricFindValue, ...metricFindOptions] : [];
});
}
}
function selectableValueToMetricFindOption({ label, value }: SelectableValue<string>): MetricFindValue {
return { text: label ?? value ?? '', value: value, expandable: true };
}