From c65213572427017facdef2b651c190e5cfb4bc11 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 28 Nov 2022 14:21:34 +0100 Subject: [PATCH] [v9.3.x] CloudWatch: Cross-account querying support (#59389) * add cross-account querying support * fix tests --- .betterer.results | 3 +- .../datasources/aws-cloudwatch/_index.md | 15 ++ .../aws-cloudwatch/query-editor/index.md | 27 ++ go.mod | 6 +- go.sum | 13 +- .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 5 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/tsdb/cloudwatch/clients/metrics.go | 13 +- pkg/tsdb/cloudwatch/clients/metrics_test.go | 30 +++ pkg/tsdb/cloudwatch/cloudwatch.go | 29 ++- pkg/tsdb/cloudwatch/cloudwatch_test.go | 96 ++++++- pkg/tsdb/cloudwatch/log_actions.go | 25 +- pkg/tsdb/cloudwatch/log_actions_test.go | 72 ++++++ .../cloudwatch/metric_data_query_builder.go | 14 +- .../metric_data_query_builder_test.go | 99 +++++++- pkg/tsdb/cloudwatch/metric_find_query.go | 2 - pkg/tsdb/cloudwatch/mocks/accounts_service.go | 16 ++ .../cloudwatch/mocks/cloudwatch_metric_api.go | 6 +- .../cloudwatch/mocks/list_metrics_service.go | 20 +- pkg/tsdb/cloudwatch/mocks/logs.go | 37 +++ pkg/tsdb/cloudwatch/mocks/metrics_client.go | 5 +- pkg/tsdb/cloudwatch/mocks/oam_client.go | 20 ++ pkg/tsdb/cloudwatch/models/api.go | 44 +++- .../cloudwatch/models/cloudwatch_query.go | 21 +- .../models/cloudwatch_query_test.go | 164 +++++++++--- ...st.go => dimension_values_request_test.go} | 0 .../resources/log_groups_resource_request.go | 53 ++++ .../resources/metrics_resource_request.go | 6 +- .../models/resources/resource_request.go | 24 +- pkg/tsdb/cloudwatch/models/resources/types.go | 24 ++ pkg/tsdb/cloudwatch/models/types.go | 22 +- pkg/tsdb/cloudwatch/resource_handler.go | 2 + pkg/tsdb/cloudwatch/routes/accounts.go | 55 ++++ pkg/tsdb/cloudwatch/routes/accounts_test.go | 96 +++++++ pkg/tsdb/cloudwatch/routes/dimension_keys.go | 2 +- .../cloudwatch/routes/dimension_keys_test.go | 21 +- .../routes/dimension_values_test.go | 19 +- pkg/tsdb/cloudwatch/routes/log_groups.go | 49 ++++ pkg/tsdb/cloudwatch/routes/log_groups_test.go | 240 ++++++++++++++++++ pkg/tsdb/cloudwatch/routes/metrics.go | 10 +- pkg/tsdb/cloudwatch/routes/metrics_test.go | 27 +- pkg/tsdb/cloudwatch/routes/namespaces.go | 14 +- pkg/tsdb/cloudwatch/routes/namespaces_test.go | 17 +- pkg/tsdb/cloudwatch/services/accounts.go | 89 +++++++ pkg/tsdb/cloudwatch/services/accounts_test.go | 165 ++++++++++++ .../cloudwatch/services/hardcoded_metrics.go | 24 +- .../services/hardcoded_metrics_test.go | 4 +- pkg/tsdb/cloudwatch/services/list_metrics.go | 63 ++--- .../cloudwatch/services/list_metrics_test.go | 170 ++++++++++--- pkg/tsdb/cloudwatch/services/logs.go | 53 ++++ pkg/tsdb/cloudwatch/services/logs_test.go | 200 +++++++++++++++ pkg/tsdb/cloudwatch/services/utils.go | 27 ++ pkg/tsdb/cloudwatch/time_series_query.go | 4 +- pkg/tsdb/cloudwatch/time_series_query_test.go | 183 +++++++++++++ pkg/tsdb/cloudwatch/utils/utils.go | 3 + pkg/tsdb/cloudwatch/utils_test.go | 3 - .../plugins/datasource/cloudwatch/Search.tsx | 39 +++ .../__mocks__/CloudWatchDataSource.ts | 16 +- .../__mocks__/dashboardOnLoadedEvent.ts | 25 ++ .../cloudwatch/__mocks__/queries.ts | 68 ++++- .../plugins/datasource/cloudwatch/api.test.ts | 24 +- .../app/plugins/datasource/cloudwatch/api.ts | 68 +++-- .../cloudwatch/components/Account.test.tsx | 65 +++++ .../cloudwatch/components/Account.tsx | 54 ++++ .../components/AnnotationQueryEditor.test.tsx | 7 + .../CrossAccountLogsQueryField.test.tsx | 190 ++++++++++++++ .../components/CrossAccountLogsQueryField.tsx | 179 +++++++++++++ .../components/Dimensions/FilterItem.tsx | 12 +- .../components/LogGroupSelection.test.tsx | 86 +++++++ .../components/LogGroupSelection.tsx | 64 +++++ .../components/LogGroupSelector.tsx | 6 + .../cloudwatch/components/LogsQueryField.tsx | 31 +-- .../MetricStatEditor.test.tsx | 56 ++++ .../MetricStatEditor/MetricStatEditor.tsx | 40 ++- .../MetricsQueryEditor.test.tsx | 1 + .../MetricsQueryHeader.test.tsx | 4 + .../MetricsQueryEditor/MetricsQueryHeader.tsx | 18 +- .../components/PanelQueryEditor.test.tsx | 86 +++++++ .../components/QueryHeader.test.tsx | 106 ++++++++ .../cloudwatch/components/QueryHeader.tsx | 39 ++- .../SQLBuilderEditor.test.tsx | 2 +- .../SQLBuilderEditor/SQLBuilderSelectRow.tsx | 2 +- .../cloudwatch/components/Search.test.tsx | 42 +++ .../VariableQueryEditor.tsx | 7 +- .../cloudwatch/components/styles.ts | 80 ++++++ .../datasource/cloudwatch/datasource.test.ts | 16 +- .../datasource/cloudwatch/datasource.ts | 4 +- .../cloudwatch/dynamic-labels/language.ts | 3 + .../datasource/cloudwatch/hooks.test.ts | 141 ++++++++++ .../plugins/datasource/cloudwatch/hooks.ts | 110 +++++++- .../query-runner/CloudWatchLogsQueryRunner.ts | 17 +- .../CloudWatchMetricsQueryRunner.test.ts | 20 ++ .../CloudWatchMetricsQueryRunner.ts | 42 ++- .../query-runner/CloudWatchRequest.ts | 4 + .../datasource/cloudwatch/tracking.test.ts | 14 +- .../plugins/datasource/cloudwatch/tracking.ts | 9 +- .../plugins/datasource/cloudwatch/types.ts | 56 ++-- .../datasource/cloudwatch/variables.test.ts | 24 ++ .../datasource/cloudwatch/variables.ts | 115 ++++----- 100 files changed, 3960 insertions(+), 488 deletions(-) create mode 100644 pkg/tsdb/cloudwatch/mocks/accounts_service.go create mode 100644 pkg/tsdb/cloudwatch/mocks/logs.go create mode 100644 pkg/tsdb/cloudwatch/mocks/oam_client.go rename pkg/tsdb/cloudwatch/models/resources/{dimension_values_rquest_test.go => dimension_values_request_test.go} (100%) create mode 100644 pkg/tsdb/cloudwatch/models/resources/log_groups_resource_request.go create mode 100644 pkg/tsdb/cloudwatch/routes/accounts.go create mode 100644 pkg/tsdb/cloudwatch/routes/accounts_test.go create mode 100644 pkg/tsdb/cloudwatch/routes/log_groups.go create mode 100644 pkg/tsdb/cloudwatch/routes/log_groups_test.go create mode 100644 pkg/tsdb/cloudwatch/services/accounts.go create mode 100644 pkg/tsdb/cloudwatch/services/accounts_test.go create mode 100644 pkg/tsdb/cloudwatch/services/logs.go create mode 100644 pkg/tsdb/cloudwatch/services/logs_test.go create mode 100644 pkg/tsdb/cloudwatch/services/utils.go create mode 100644 pkg/tsdb/cloudwatch/utils/utils.go create mode 100644 public/app/plugins/datasource/cloudwatch/Search.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/Account.test.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/Account.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/CrossAccountLogsQueryField.test.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/CrossAccountLogsQueryField.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/LogGroupSelection.test.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/LogGroupSelection.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/Search.test.tsx create mode 100644 public/app/plugins/datasource/cloudwatch/components/styles.ts create mode 100644 public/app/plugins/datasource/cloudwatch/hooks.test.ts diff --git a/.betterer.results b/.betterer.results index 49f5766f7d4..777be480c39 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5379,8 +5379,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"] diff --git a/docs/sources/datasources/aws-cloudwatch/_index.md b/docs/sources/datasources/aws-cloudwatch/_index.md index 2c2ce5f34bd..6a384f95ea6 100644 --- a/docs/sources/datasources/aws-cloudwatch/_index.md +++ b/docs/sources/datasources/aws-cloudwatch/_index.md @@ -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 diff --git a/docs/sources/datasources/aws-cloudwatch/query-editor/index.md b/docs/sources/datasources/aws-cloudwatch/query-editor/index.md index d58c0402c6e..5110aeab486 100644 --- a/docs/sources/datasources/aws-cloudwatch/query-editor/index.md +++ b/docs/sources/datasources/aws-cloudwatch/query-editor/index.md @@ -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" >}} diff --git a/go.mod b/go.mod index 9edbe559b78..32f1acf7835 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 9abdd47c2e5..09ebf60b41a 100644 --- a/go.sum +++ b/go.sum @@ -368,8 +368,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= @@ -2851,8 +2852,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= @@ -3073,14 +3074,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= diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c4b9823408c..15e941314aa 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -69,6 +69,7 @@ export interface FeatureToggles { objectStore?: boolean; traceqlEditor?: boolean; flameGraph?: boolean; + cloudWatchCrossAccountQuerying?: boolean; redshiftAsyncQueryDataSupport?: boolean; athenaAsyncQueryDataSupport?: boolean; increaseInMemDatabaseQueryCache?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 542f9b4a53c..b6f53dbf3ed 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -300,6 +300,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", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 2af681298cf..18d7c166e33 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -219,6 +219,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" diff --git a/pkg/tsdb/cloudwatch/clients/metrics.go b/pkg/tsdb/cloudwatch/clients/metrics.go index d205c968cee..f49743d49ea 100644 --- a/pkg/tsdb/cloudwatch/clients/metrics.go +++ b/pkg/tsdb/cloudwatch/clients/metrics.go @@ -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 diff --git a/pkg/tsdb/cloudwatch/clients/metrics_test.go b/pkg/tsdb/cloudwatch/clients/metrics_test.go index 91636775ba5..0a28e34c5a2 100644 --- a/pkg/tsdb/cloudwatch/clients/metrics_test.go +++ b/pkg/tsdb/cloudwatch/clients/metrics_test.go @@ -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 } diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 9da0fb861d1..33426b105ab 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -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. diff --git a/pkg/tsdb/cloudwatch/cloudwatch_test.go b/pkg/tsdb/cloudwatch/cloudwatch_test.go index bca9bbe4e80..a11c15889fe 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch_test.go +++ b/pkg/tsdb/cloudwatch/cloudwatch_test.go @@ -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) }) } diff --git a/pkg/tsdb/cloudwatch/log_actions.go b/pkg/tsdb/cloudwatch/log_actions.go index f2f52d9cd64..33344572afe 100644 --- a/pkg/tsdb/cloudwatch/log_actions.go +++ b/pkg/tsdb/cloudwatch/log_actions.go @@ -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) } diff --git a/pkg/tsdb/cloudwatch/log_actions_test.go b/pkg/tsdb/cloudwatch/log_actions_test.go index 00985a930f8..919f3d5b0c1 100644 --- a/pkg/tsdb/cloudwatch/log_actions_test.go +++ b/pkg/tsdb/cloudwatch/log_actions_test.go @@ -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) { diff --git a/pkg/tsdb/cloudwatch/metric_data_query_builder.go b/pkg/tsdb/cloudwatch/metric_data_query_builder.go index 5baf0bb872b..b921a88739a 100644 --- a/pkg/tsdb/cloudwatch/metric_data_query_builder.go +++ b/pkg/tsdb/cloudwatch/metric_data_query_builder.go @@ -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 { diff --git a/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go b/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go index b13c389504c..84b827ec925 100644 --- a/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go +++ b/pkg/tsdb/cloudwatch/metric_data_query_builder_test.go @@ -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) { diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index cb7c658c483..cc4e1f3904f 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -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 diff --git a/pkg/tsdb/cloudwatch/mocks/accounts_service.go b/pkg/tsdb/cloudwatch/mocks/accounts_service.go new file mode 100644 index 00000000000..52f3aff63b5 --- /dev/null +++ b/pkg/tsdb/cloudwatch/mocks/accounts_service.go @@ -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) +} diff --git a/pkg/tsdb/cloudwatch/mocks/cloudwatch_metric_api.go b/pkg/tsdb/cloudwatch/mocks/cloudwatch_metric_api.go index 8508f6e5697..b36077229a5 100644 --- a/pkg/tsdb/cloudwatch/mocks/cloudwatch_metric_api.go +++ b/pkg/tsdb/cloudwatch/mocks/cloudwatch_metric_api.go @@ -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 diff --git a/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go b/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go index e809bc8518c..8c9fb73f697 100644 --- a/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go +++ b/pkg/tsdb/cloudwatch/mocks/list_metrics_service.go @@ -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) } diff --git a/pkg/tsdb/cloudwatch/mocks/logs.go b/pkg/tsdb/cloudwatch/mocks/logs.go new file mode 100644 index 00000000000..c6210eeceac --- /dev/null +++ b/pkg/tsdb/cloudwatch/mocks/logs.go @@ -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) +} diff --git a/pkg/tsdb/cloudwatch/mocks/metrics_client.go b/pkg/tsdb/cloudwatch/mocks/metrics_client.go index 3b0d818f7f4..057389ba71e 100644 --- a/pkg/tsdb/cloudwatch/mocks/metrics_client.go +++ b/pkg/tsdb/cloudwatch/mocks/metrics_client.go @@ -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) } diff --git a/pkg/tsdb/cloudwatch/mocks/oam_client.go b/pkg/tsdb/cloudwatch/mocks/oam_client.go new file mode 100644 index 00000000000..2d617cc143a --- /dev/null +++ b/pkg/tsdb/cloudwatch/mocks/oam_client.go @@ -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) +} diff --git a/pkg/tsdb/cloudwatch/models/api.go b/pkg/tsdb/cloudwatch/models/api.go index b86860e5efd..41455687f5f 100644 --- a/pkg/tsdb/cloudwatch/models/api.go +++ b/pkg/tsdb/cloudwatch/models/api.go @@ -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) +} diff --git a/pkg/tsdb/cloudwatch/models/cloudwatch_query.go b/pkg/tsdb/cloudwatch/models/cloudwatch_query.go index 248796e35b5..a6cf683152e 100644 --- a/pkg/tsdb/cloudwatch/models/cloudwatch_query.go +++ b/pkg/tsdb/cloudwatch/models/cloudwatch_query.go @@ -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, diff --git a/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go b/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go index d2467b2ed46..89459f66bea 100644 --- a/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go +++ b/pkg/tsdb/cloudwatch/models/cloudwatch_query_test.go @@ -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 } diff --git a/pkg/tsdb/cloudwatch/models/resources/dimension_values_rquest_test.go b/pkg/tsdb/cloudwatch/models/resources/dimension_values_request_test.go similarity index 100% rename from pkg/tsdb/cloudwatch/models/resources/dimension_values_rquest_test.go rename to pkg/tsdb/cloudwatch/models/resources/dimension_values_request_test.go diff --git a/pkg/tsdb/cloudwatch/models/resources/log_groups_resource_request.go b/pkg/tsdb/cloudwatch/models/resources/log_groups_resource_request.go new file mode 100644 index 00000000000..bf1502c75d0 --- /dev/null +++ b/pkg/tsdb/cloudwatch/models/resources/log_groups_resource_request.go @@ -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 ¶mValue +} + +func getLimit(limit string) int64 { + logGroupLimit := defaultLogGroupLimit + intLimit, err := strconv.ParseInt(limit, 10, 64) + if err == nil && intLimit > 0 { + logGroupLimit = intLimit + } + return logGroupLimit +} diff --git a/pkg/tsdb/cloudwatch/models/resources/metrics_resource_request.go b/pkg/tsdb/cloudwatch/models/resources/metrics_resource_request.go index 6bc749348cd..2aaf53931f4 100644 --- a/pkg/tsdb/cloudwatch/models/resources/metrics_resource_request.go +++ b/pkg/tsdb/cloudwatch/models/resources/metrics_resource_request.go @@ -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 diff --git a/pkg/tsdb/cloudwatch/models/resources/resource_request.go b/pkg/tsdb/cloudwatch/models/resources/resource_request.go index e13c11cb558..53b1525b62b 100644 --- a/pkg/tsdb/cloudwatch/models/resources/resource_request.go +++ b/pkg/tsdb/cloudwatch/models/resources/resource_request.go @@ -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 +} diff --git a/pkg/tsdb/cloudwatch/models/resources/types.go b/pkg/tsdb/cloudwatch/models/resources/types.go index a218740a438..728d74c5af6 100644 --- a/pkg/tsdb/cloudwatch/models/resources/types.go +++ b/pkg/tsdb/cloudwatch/models/resources/types.go @@ -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"` +} diff --git a/pkg/tsdb/cloudwatch/models/types.go b/pkg/tsdb/cloudwatch/models/types.go index df41487234d..70664869e3e 100644 --- a/pkg/tsdb/cloudwatch/models/types.go +++ b/pkg/tsdb/cloudwatch/models/types.go @@ -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"` } diff --git a/pkg/tsdb/cloudwatch/resource_handler.go b/pkg/tsdb/cloudwatch/resource_handler.go index d9c88baebd8..55d95b7caff 100644 --- a/pkg/tsdb/cloudwatch/resource_handler.go +++ b/pkg/tsdb/cloudwatch/resource_handler.go @@ -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 } diff --git a/pkg/tsdb/cloudwatch/routes/accounts.go b/pkg/tsdb/cloudwatch/routes/accounts.go new file mode 100644 index 00000000000..8d125c55ba4 --- /dev/null +++ b/pkg/tsdb/cloudwatch/routes/accounts.go @@ -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 +} diff --git a/pkg/tsdb/cloudwatch/routes/accounts_test.go b/pkg/tsdb/cloudwatch/routes/accounts_test.go new file mode 100644 index 00000000000..6562f46b360 --- /dev/null +++ b/pkg/tsdb/cloudwatch/routes/accounts_test.go @@ -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()) + }) +} diff --git a/pkg/tsdb/cloudwatch/routes/dimension_keys.go b/pkg/tsdb/cloudwatch/routes/dimension_keys.go index 302225dbed3..906f8229170 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_keys.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_keys.go @@ -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) diff --git a/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go b/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go index e48fa7dabb6..4b02727ffd4 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_keys_test.go @@ -24,7 +24,14 @@ var logger = &logtest.Fake{} func Test_DimensionKeys_Route(t *testing.T) { t.Run("calls FilterDimensionKeysRequest when a StandardDimensionKeysRequest is passed", func(t *testing.T) { mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetDimensionKeysByDimensionFilter", mock.Anything).Return([]string{}, nil).Once() + mockListMetricsService.On("GetDimensionKeysByDimensionFilter", mock.MatchedBy(func(r resources.DimensionKeysRequest) bool { + return r.ResourceRequest != nil && *r.ResourceRequest == resources.ResourceRequest{Region: "us-east-2"} && + r.Namespace == "AWS/EC2" && + r.MetricName == "CPUUtilization" && + 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([]resources.ResourceResponse[string]{}, nil).Once() newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } @@ -32,12 +39,6 @@ func Test_DimensionKeys_Route(t *testing.T) { req := httptest.NewRequest("GET", `/dimension-keys?region=us-east-2&namespace=AWS/EC2&metricName=CPUUtilization&dimensionFilters={"NodeID":["Shared"],"stage":["QueryCommit"]}`, nil) handler := http.HandlerFunc(ResourceRequestMiddleware(DimensionKeysHandler, logger, nil)) handler.ServeHTTP(rr, req) - mockListMetricsService.AssertCalled(t, "GetDimensionKeysByDimensionFilter", resources.DimensionKeysRequest{ - ResourceRequest: &resources.ResourceRequest{Region: "us-east-2"}, - Namespace: "AWS/EC2", - MetricName: "CPUUtilization", - DimensionFilter: []*resources.Dimension{{Name: "NodeID", Value: "Shared"}, {Name: "stage", Value: "QueryCommit"}}, - }) }) t.Run("calls GetHardCodedDimensionKeysByNamespace when a StandardDimensionKeysRequest is passed", func(t *testing.T) { @@ -47,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) @@ -65,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 } diff --git a/pkg/tsdb/cloudwatch/routes/dimension_values_test.go b/pkg/tsdb/cloudwatch/routes/dimension_values_test.go index c2853a8c98b..8ecdf240666 100644 --- a/pkg/tsdb/cloudwatch/routes/dimension_values_test.go +++ b/pkg/tsdb/cloudwatch/routes/dimension_values_test.go @@ -18,7 +18,15 @@ import ( func Test_DimensionValues_Route(t *testing.T) { t.Run("Calls GetDimensionValuesByDimensionFilter when a valid request is passed", func(t *testing.T) { mockListMetricsService := mocks.ListMetricsServiceMock{} - mockListMetricsService.On("GetDimensionValuesByDimensionFilter", mock.Anything).Return([]string{}, nil).Once() + mockListMetricsService.On("GetDimensionValuesByDimensionFilter", mock.MatchedBy(func(r resources.DimensionValuesRequest) bool { + return r.ResourceRequest != nil && *r.ResourceRequest == resources.ResourceRequest{Region: "us-east-2"} && + r.Namespace == "AWS/EC2" && + r.MetricName == "CPUUtilization" && + r.DimensionKey == "instanceId" && + 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([]resources.ResourceResponse[string]{}, nil).Once() newListMetricsService = func(pluginCtx backend.PluginContext, reqCtxFactory models.RequestContextFactoryFunc, region string) (models.ListMetricsProvider, error) { return &mockListMetricsService, nil } @@ -26,18 +34,11 @@ func Test_DimensionValues_Route(t *testing.T) { req := httptest.NewRequest("GET", `/dimension-values?region=us-east-2&dimensionKey=instanceId&namespace=AWS/EC2&metricName=CPUUtilization&dimensionFilters={"NodeID":["Shared"],"stage":["QueryCommit"]}`, nil) handler := http.HandlerFunc(ResourceRequestMiddleware(DimensionValuesHandler, logger, nil)) handler.ServeHTTP(rr, req) - mockListMetricsService.AssertCalled(t, "GetDimensionValuesByDimensionFilter", resources.DimensionValuesRequest{ - ResourceRequest: &resources.ResourceRequest{Region: "us-east-2"}, - Namespace: "AWS/EC2", - MetricName: "CPUUtilization", - DimensionKey: "instanceId", - DimensionFilter: []*resources.Dimension{{Name: "NodeID", Value: "Shared"}, {Name: "stage", Value: "QueryCommit"}}, - }) }) 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 } diff --git a/pkg/tsdb/cloudwatch/routes/log_groups.go b/pkg/tsdb/cloudwatch/routes/log_groups.go new file mode 100644 index 00000000000..d5a803ec01d --- /dev/null +++ b/pkg/tsdb/cloudwatch/routes/log_groups.go @@ -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 +} diff --git a/pkg/tsdb/cloudwatch/routes/log_groups_test.go b/pkg/tsdb/cloudwatch/routes/log_groups_test.go new file mode 100644 index 00000000000..4902679c93a --- /dev/null +++ b/pkg/tsdb/cloudwatch/routes/log_groups_test.go @@ -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()) + }) +} diff --git a/pkg/tsdb/cloudwatch/routes/metrics.go b/pkg/tsdb/cloudwatch/routes/metrics.go index db90f7b2f2c..54aadfc78c0 100644 --- a/pkg/tsdb/cloudwatch/routes/metrics.go +++ b/pkg/tsdb/cloudwatch/routes/metrics.go @@ -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) } diff --git a/pkg/tsdb/cloudwatch/routes/metrics_test.go b/pkg/tsdb/cloudwatch/routes/metrics_test.go index 061a846012c..b11da17af18 100644 --- a/pkg/tsdb/cloudwatch/routes/metrics_test.go +++ b/pkg/tsdb/cloudwatch/routes/metrics_test.go @@ -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 } diff --git a/pkg/tsdb/cloudwatch/routes/namespaces.go b/pkg/tsdb/cloudwatch/routes/namespaces.go index 040748c5407..a79f7bb0def 100644 --- a/pkg/tsdb/cloudwatch/routes/namespaces.go +++ b/pkg/tsdb/cloudwatch/routes/namespaces.go @@ -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) } diff --git a/pkg/tsdb/cloudwatch/routes/namespaces_test.go b/pkg/tsdb/cloudwatch/routes/namespaces_test.go index 9eeec6f0a94..f2006677b19 100644 --- a/pkg/tsdb/cloudwatch/routes/namespaces_test.go +++ b/pkg/tsdb/cloudwatch/routes/namespaces_test.go @@ -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()) }) } diff --git a/pkg/tsdb/cloudwatch/services/accounts.go b/pkg/tsdb/cloudwatch/services/accounts.go new file mode 100644 index 00000000000..ab8db64147e --- /dev/null +++ b/pkg/tsdb/cloudwatch/services/accounts.go @@ -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 +} diff --git a/pkg/tsdb/cloudwatch/services/accounts_test.go b/pkg/tsdb/cloudwatch/services/accounts_test.go new file mode 100644 index 00000000000..7e74e6fbec9 --- /dev/null +++ b/pkg/tsdb/cloudwatch/services/accounts_test.go @@ -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") + }) +} diff --git a/pkg/tsdb/cloudwatch/services/hardcoded_metrics.go b/pkg/tsdb/cloudwatch/services/hardcoded_metrics.go index 2268089b566..8680fdf7184 100644 --- a/pkg/tsdb/cloudwatch/services/hardcoded_metrics.go +++ b/pkg/tsdb/cloudwatch/services/hardcoded_metrics.go @@ -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) } diff --git a/pkg/tsdb/cloudwatch/services/hardcoded_metrics_test.go b/pkg/tsdb/cloudwatch/services/hardcoded_metrics_test.go index 4895ef9dc61..e5c9d4f6ade 100644 --- a/pkg/tsdb/cloudwatch/services/hardcoded_metrics_test.go +++ b/pkg/tsdb/cloudwatch/services/hardcoded_metrics_test.go @@ -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) }) } diff --git a/pkg/tsdb/cloudwatch/services/list_metrics.go b/pkg/tsdb/cloudwatch/services/list_metrics.go index 8ab2d2bebad..f4f66914dfe 100644 --- a/pkg/tsdb/cloudwatch/services/list_metrics.go +++ b/pkg/tsdb/cloudwatch/services/list_metrics.go @@ -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 + } + } +} diff --git a/pkg/tsdb/cloudwatch/services/list_metrics_test.go b/pkg/tsdb/cloudwatch/services/list_metrics_test.go index 34548ffaa98..8d63baabcbf 100644 --- a/pkg/tsdb/cloudwatch/services/list_metrics_test.go +++ b/pkg/tsdb/cloudwatch/services/list_metrics_test.go @@ -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) + }) + } } diff --git a/pkg/tsdb/cloudwatch/services/logs.go b/pkg/tsdb/cloudwatch/services/logs.go new file mode 100644 index 00000000000..64658bc51df --- /dev/null +++ b/pkg/tsdb/cloudwatch/services/logs.go @@ -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 +} diff --git a/pkg/tsdb/cloudwatch/services/logs_test.go b/pkg/tsdb/cloudwatch/services/logs_test.go new file mode 100644 index 00000000000..a1b81a8d094 --- /dev/null +++ b/pkg/tsdb/cloudwatch/services/logs_test.go @@ -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"), + }) + }) +} diff --git a/pkg/tsdb/cloudwatch/services/utils.go b/pkg/tsdb/cloudwatch/services/utils.go new file mode 100644 index 00000000000..4684aa2acad --- /dev/null +++ b/pkg/tsdb/cloudwatch/services/utils.go @@ -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 "" +} diff --git a/pkg/tsdb/cloudwatch/time_series_query.go b/pkg/tsdb/cloudwatch/time_series_query.go index b3d2fc4d8b3..fbec70a42b1 100644 --- a/pkg/tsdb/cloudwatch/time_series_query.go +++ b/pkg/tsdb/cloudwatch/time_series_query.go @@ -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 } diff --git a/pkg/tsdb/cloudwatch/time_series_query_test.go b/pkg/tsdb/cloudwatch/time_series_query_test.go index 1dd7042ada6..29728c729fc 100644 --- a/pkg/tsdb/cloudwatch/time_series_query_test.go +++ b/pkg/tsdb/cloudwatch/time_series_query_test.go @@ -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) + }) +} diff --git a/pkg/tsdb/cloudwatch/utils/utils.go b/pkg/tsdb/cloudwatch/utils/utils.go new file mode 100644 index 00000000000..65a4cc8ba4d --- /dev/null +++ b/pkg/tsdb/cloudwatch/utils/utils.go @@ -0,0 +1,3 @@ +package utils + +func Pointer[T any](arg T) *T { return &arg } diff --git a/pkg/tsdb/cloudwatch/utils_test.go b/pkg/tsdb/cloudwatch/utils_test.go index 90577a090dc..0b3a07039c0 100644 --- a/pkg/tsdb/cloudwatch/utils_test.go +++ b/pkg/tsdb/cloudwatch/utils_test.go @@ -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) } diff --git a/public/app/plugins/datasource/cloudwatch/Search.tsx b/public/app/plugins/datasource/cloudwatch/Search.tsx new file mode 100644 index 00000000000..e7af681ffe1 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/Search.tsx @@ -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 ( + } + value={searchFilter} + onChange={(event) => { + const searchPhrase = event.currentTarget.value; + setSearchFilter(searchPhrase); + debouncedSearch(searchPhrase); + }} + placeholder="search by log group name prefix" + /> + ); +}; + +export default Search; diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts index dcc5c5364ef..192561b8e1b 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts @@ -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, +}; diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/dashboardOnLoadedEvent.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/dashboardOnLoadedEvent.ts index 1161f75b6e5..8ba9e8149c6 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/dashboardOnLoadedEvent.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/dashboardOnLoadedEvent.ts @@ -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: { diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/queries.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/queries.ts index ebd054e6610..1280b72be72 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/queries.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/queries.ts @@ -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, }; diff --git a/public/app/plugins/datasource/cloudwatch/api.test.ts b/public/app/plugins/datasource/cloudwatch/api.test.ts index 6199876b77e..99fbbcc32f2 100644 --- a/public/app/plugins/datasource/cloudwatch/api.test.ts +++ b/public/app/plugins/datasource/cloudwatch/api.test.ts @@ -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 }); diff --git a/public/app/plugins/datasource/cloudwatch/api.ts b/public/app/plugins/datasource/cloudwatch/api.ts index a6342b11499..1edf5b49bad 100644 --- a/public/app/plugins/datasource/cloudwatch/api.ts +++ b/public/app/plugins/datasource/cloudwatch/api.ts @@ -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 { @@ -33,6 +37,18 @@ export class CloudWatchAPI extends CloudWatchRequest { return getBackendSrv().get(`/api/datasources/${this.instanceSettings.id}/resources/${subtype}`, parameters); } + getAccounts({ region }: ResourceRequest): Promise { + return this.memoizedGetRequest>>('accounts', { + region: this.templateSrv.replace(region), + }).then((accounts) => accounts.map((a) => a.value)); + } + + isMonitoringAccount(region: string): Promise { + return this.getAccounts({ region }) + .then((accounts) => accounts.some((account) => account.isMonitoringAccount)) + .catch(() => false); + } + getRegions() { return this.memoizedGetRequest('regions').then((regions) => [ { label: 'default', value: 'default', text: 'default' }, @@ -41,8 +57,8 @@ export class CloudWatchAPI extends CloudWatchRequest { } getNamespaces() { - return this.memoizedGetRequest('namespaces').then((namespaces) => - namespaces.map((n) => ({ label: n, value: n })) + return this.memoizedGetRequest>>('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 { + return this.memoizedGetRequest>>('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('all-log-groups', { ...params, @@ -60,21 +90,26 @@ export class CloudWatchAPI extends CloudWatchRequest { }); } - async getMetrics({ region, namespace }: GetMetricsRequest): Promise>> { + async getMetrics({ region, namespace, accountId }: GetMetricsRequest): Promise>> { if (!namespace) { return []; } - return this.memoizedGetRequest('metrics', { + return this.memoizedGetRequest>>('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> { - return this.memoizedGetRequest('metrics', { + async getAllMetrics({ + region, + accountId, + }: GetMetricsRequest): Promise> { + return this.memoizedGetRequest>>('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>> { - return this.memoizedGetRequest('dimension-keys', { + return this.memoizedGetRequest>>('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('dimension-values', { + const values = await this.memoizedGetRequest>>('dimension-values', { region: this.templateSrv.replace(this.getActualRegion(region)), namespace: this.templateSrv.replace(namespace), metricName: this.templateSrv.replace(metricName.trim()), dimensionKey: this.templateSrv.replace(dimensionKey), 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; } diff --git a/public/app/plugins/datasource/cloudwatch/components/Account.test.tsx b/public/app/plugins/datasource/cloudwatch/components/Account.test.tsx new file mode 100644 index 00000000000..e2803524fd1 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/Account.test.tsx @@ -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(); + 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(); + 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(); + expect(screen.getByLabelText('Account Selection')).toBeInTheDocument(); + expect(screen.getByText('All')).toBeInTheDocument(); + }); + + it('should select an uninterpolated template variable if it has been selected', () => { + render(); + expect(screen.getByLabelText('Account Selection')).toBeInTheDocument(); + expect(screen.getByText('$fakeVar')).toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/Account.tsx b/public/app/plugins/datasource/cloudwatch/components/Account.tsx new file mode 100644 index 00000000000..57cde6ac83a --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/Account.tsx @@ -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>; + 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) => { + return tempVar.value === accountId; + }); + return matchingTemplateVar; + } + return a.value === accountId; + }), + [accountOptions, accountId] + ); + + if (accountOptions.length === 0) { + return null; + } + + return ( + + - - {!disableExpressions && ( - + {!disableExpressions && ( - - )} + )} + ); } diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx index 0c121d3df42..0b8b92f488f 100644 --- a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx @@ -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: { diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.test.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.test.tsx index ad1763a06af..1f0d639620e 100644 --- a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.test.tsx @@ -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} /> ); diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.tsx index 020f7cd505f..6520db778ae 100644 --- a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryHeader.tsx @@ -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> = [ @@ -30,6 +32,7 @@ const MetricsQueryHeader: React.FC = ({ sqlCodeEditorIsDirty, onChange, onRunQuery, + isMonitoringAccount, }) => { const { metricEditorMode, metricQueryType } = query; const [showConfirm, setShowConfirm] = useState(false); @@ -49,6 +52,11 @@ const MetricsQueryHeader: React.FC = ({ [setShowConfirm, onChange, sqlCodeEditorIsDirty, query, metricEditorMode, metricQueryType] ); + const shouldDisplayMonitoringBadge = + query.metricQueryType === MetricQueryType.Search && + isMonitoringAccount && + config.featureToggles.cloudWatchCrossAccountQuerying; + return ( <> = ({ /> + {shouldDisplayMonitoringBadge && ( + + )} + {query.metricQueryType === MetricQueryType.Query && query.metricEditorMode === MetricEditorMode.Code && ( diff --git a/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.test.tsx index eefeca932f7..0a62600b4d2 100644 --- a/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.test.tsx @@ -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; + 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(); + }); + 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(); + }); + expect(await screen.queryByText('Monitoring account')).toBeNull(); + }); + }); + }); }); diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.test.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.test.tsx index 8bd83cf3207..7a2a0b10b40 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.test.tsx @@ -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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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(); + }); + }); }); diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx index e8bccd0231b..5d302f5b083 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx @@ -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; } const apiModes: Array> = [ @@ -26,6 +28,7 @@ const apiModes: Array> = [ const QueryHeader: React.FC = ({ 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 = ({ query, sqlCodeEditorIsDirty, } as CloudWatchQuery); } }; - - const onRegion = async ({ value }: SelectableValue) => { - 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 ( = ({ 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} /> + {shouldDisplayMonitoringBadge && ( + <> + + + + )} + {queryMode === ExploreMode.Metrics && ( )} diff --git a/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderEditor.test.tsx index 30148482919..fa4060aaa12 100644 --- a/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderEditor.test.tsx @@ -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: '', diff --git a/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderSelectRow.tsx b/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderSelectRow.tsx index b80a3a4c71b..ab1a8551dc2 100644 --- a/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderSelectRow.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/SQLBuilderEditor/SQLBuilderSelectRow.tsx @@ -48,7 +48,7 @@ const SQLBuilderSelectRow: React.FC = ({ 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, diff --git a/public/app/plugins/datasource/cloudwatch/components/Search.test.tsx b/public/app/plugins/datasource/cloudwatch/components/Search.test.tsx new file mode 100644 index 00000000000..6312770e21a --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/Search.test.tsx @@ -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(); + expect(await screen.findByDisplayValue('testPhrase')).toBeInTheDocument(); + }); + + it('displays placeholder text if search phrase is not passed in', async () => { + render(); + 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(); + await userEvent.type(await screen.findByLabelText('log group search'), 'something'); + expect(searchFn).toBeCalledWith('s'); + expect(searchFn).toHaveBeenLastCalledWith('something'); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx index bed581412dd..5e474a1cf51 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx @@ -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, diff --git a/public/app/plugins/datasource/cloudwatch/components/styles.ts b/public/app/plugins/datasource/cloudwatch/components/styles.ts new file mode 100644 index 00000000000..3334f8870f0 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/styles.ts @@ -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; diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts index d176bfc8b97..9cf9c7cb9e1 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts @@ -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; diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 59b33011f62..1d354da4311 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -53,7 +53,7 @@ export class CloudWatchDatasource constructor( instanceSettings: DataSourceInstanceSettings, - 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) { diff --git a/public/app/plugins/datasource/cloudwatch/dynamic-labels/language.ts b/public/app/plugins/datasource/cloudwatch/dynamic-labels/language.ts index 690334c5b6a..526fd048527 100644 --- a/public/app/plugins/datasource/cloudwatch/dynamic-labels/language.ts +++ b/public/app/plugins/datasource/cloudwatch/dynamic-labels/language.ts @@ -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 = { diff --git a/public/app/plugins/datasource/cloudwatch/hooks.test.ts b/public/app/plugins/datasource/cloudwatch/hooks.test.ts new file mode 100644 index 00000000000..4512ee8723c --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/hooks.test.ts @@ -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' }] }, + ]); + }); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/hooks.ts b/public/app/plugins/datasource/cloudwatch/hooks.ts index 845b12913c0..288c323e5e8 100644 --- a/public/app/plugins/datasource/cloudwatch/hooks.ts +++ b/public/app/plugins/datasource/cloudwatch/hooks.ts @@ -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>, 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>>([]); + + // 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>) => { + datasource.api.getMetrics({ namespace, region, accountId }).then((result: Array>) => { 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>>([]); + // 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>) => { 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, + 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> = accounts.map((a) => ({ + label: a.label, + value: a.id, + description: a.id, + })); + + const variableOptions = api.getVariables().map(toOption); + + const variableOptionGroup: SelectableValue = { + label: 'Template Variables', + options: variableOptions, + }; + + return [...options, variableOptionGroup]; + }; + + const [state, doFetch] = useAsyncFn(fetchAccountOptions, [api, region]); + + useEffect(() => { + doFetch(); + }, [api, region, doFetch]); + + return state; +}; diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts index 2596de7441f..ef2edf86ac0 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts @@ -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(); diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts index 8464a526ebb..cf4d7154e76 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts @@ -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], diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.ts index 85f30e1c954..978075a3a4d 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.ts @@ -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 - ): 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) { - 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)) { diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchRequest.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchRequest.ts index 435a35a0e0e..9b344b767d2 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchRequest.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchRequest.ts @@ -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) => diff --git a/public/app/plugins/datasource/cloudwatch/tracking.test.ts b/public/app/plugins/datasource/cloudwatch/tracking.test.ts index 4827233b79a..670ec03ed75 100644 --- a/public/app/plugins/datasource/cloudwatch/tracking.test.ts +++ b/public/app/plugins/datasource/cloudwatch/tracking.test.ts @@ -1,6 +1,6 @@ import { DashboardLoadedEvent } from '@grafana/data'; let handler: (e: DashboardLoadedEvent) => {}; -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; }); }); diff --git a/public/app/plugins/datasource/cloudwatch/tracking.ts b/public/app/plugins/datasource/cloudwatch/tracking.ts index f9a69d6dbea..a44c6d866a3 100644 --- a/public/app/plugins/datasource/cloudwatch/tracking.ts +++ b/public/app/plugins/datasource/cloudwatch/tracking.ts @@ -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); diff --git a/public/app/plugins/datasource/cloudwatch/types.ts b/public/app/plugins/datasource/cloudwatch/types.ts index 6347557c126..d656913493d 100644 --- a/public/app/plugins/datasource/cloudwatch/types.ts +++ b/public/app/plugins/datasource/cloudwatch/types.ts @@ -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 { results: Record>; 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 { + accountId?: string; + value: T; +} diff --git a/public/app/plugins/datasource/cloudwatch/variables.test.ts b/public/app/plugins/datasource/cloudwatch/variables.test.ts index 36c1708bd1f..0fc0f282d83 100644 --- a/public/app/plugins/datasource/cloudwatch/variables.test.ts +++ b/public/app/plugins/datasource/cloudwatch/variables.test.ts @@ -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, diff --git a/public/app/plugins/datasource/cloudwatch/variables.ts b/public/app/plugins/datasource/cloudwatch/variables.ts index 54339e27e9e..25629c87ff3 100644 --- a/public/app/plugins/datasource/cloudwatch/variables.ts +++ b/public/app/plugins/datasource/cloudwatch/variables.ts @@ -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 ({ - 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 ({ - text: s.label, - value: s.value, - expandable: true, - })); + return keys.map(selectableValueToMetricFindOption); } async handleStatisticsQuery() { @@ -162,4 +132,21 @@ export class CloudWatchVariableSupport extends CustomVariableSupport { + const metricFindOptions = accounts.map((account) => ({ + text: account.label, + value: account.id, + expandable: true, + })); + + return metricFindOptions.length ? [this.allMetricFindValue, ...metricFindOptions] : []; + }); + } +} + +function selectableValueToMetricFindOption({ label, value }: SelectableValue): MetricFindValue { + return { text: label ?? value ?? '', value: value, expandable: true }; }