CloudWatch Logs: Limit CloudWatch logs queries to use logGroupIdentifiers only for monitoring accounts (#113137)
* Set the log group name when executing log queries from the frontend * Add helper for a data source instance to check if its a monitoring account * Execute log queries with log group identifiers only for monitoring account queries * fix cloudwatch datasource.ts tests * remove unneeded check
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
@@ -54,9 +55,10 @@ type DataSource struct {
|
||||
ProxyOpts *proxy.Options
|
||||
AWSConfigProvider awsauth.ConfigProvider
|
||||
|
||||
logger log.Logger
|
||||
tagValueCache *cache.Cache
|
||||
resourceHandler backend.CallResourceHandler
|
||||
logger log.Logger
|
||||
tagValueCache *cache.Cache
|
||||
resourceHandler backend.CallResourceHandler
|
||||
monitoringAccountCache sync.Map
|
||||
}
|
||||
|
||||
func (ds *DataSource) newAWSConfig(ctx context.Context, region string) (aws.Config, error) {
|
||||
@@ -273,6 +275,33 @@ func (ds *DataSource) getRGTAClient(ctx context.Context, region string) (resourc
|
||||
return NewRGTAClient(cfg), nil
|
||||
}
|
||||
|
||||
func (ds *DataSource) isMonitoringAccount(ctx context.Context, region string) (bool, error) {
|
||||
if value, ok := ds.monitoringAccountCache.Load(region); ok {
|
||||
cached := value.(bool)
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
client, err := ds.GetAccountsService(ctx, region)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
accounts, err := client.GetAccountsForCurrentUserOrRole(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, account := range accounts {
|
||||
if account.Value.IsMonitoringAccount {
|
||||
ds.monitoringAccountCache.Store(region, true)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
ds.monitoringAccountCache.Store(region, false)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var terminatedStates = []cloudwatchlogstypes.QueryStatus{
|
||||
cloudwatchlogstypes.QueryStatusComplete,
|
||||
cloudwatchlogstypes.QueryStatusCancelled,
|
||||
|
||||
@@ -213,17 +213,48 @@ func (ds *DataSource) executeStartQuery(ctx context.Context, logsClient models.C
|
||||
|
||||
// log group identifiers can be left out if the query is an SQL query
|
||||
if *logsQuery.QueryLanguage != dataquery.LogsQueryLanguageSQL {
|
||||
if len(logsQuery.LogGroups) > 0 && features.IsEnabled(ctx, features.FlagCloudWatchCrossAccountQuerying) {
|
||||
var logGroupIdentifiers []string
|
||||
for _, lg := range logsQuery.LogGroups {
|
||||
arn := lg.Arn
|
||||
// due to a bug in the startQuery api, we remove * from the arn, otherwise it throws an error
|
||||
logGroupIdentifiers = append(logGroupIdentifiers, strings.TrimSuffix(arn, "*"))
|
||||
useLogGroupIdentifiers := false
|
||||
logGroupsFromQuery := len(logsQuery.LogGroups) > 0
|
||||
if logGroupsFromQuery && features.IsEnabled(ctx, features.FlagCloudWatchCrossAccountQuerying) {
|
||||
region := logsQuery.Region
|
||||
if region == "" || region == defaultRegion {
|
||||
region = ds.Settings.Region
|
||||
}
|
||||
if region != "" {
|
||||
isMonitoringAccount, err := ds.isMonitoringAccount(ctx, region)
|
||||
if err != nil {
|
||||
ds.logger.FromContext(ctx).Debug("failed to determine monitoring account status", "err", err)
|
||||
} else if isMonitoringAccount {
|
||||
// monitoring accounts require querying by log group identifiers because log group names are not unique across accounts.
|
||||
var logGroupIdentifiers []string
|
||||
for _, lg := range logsQuery.LogGroups {
|
||||
// due to a bug in the startQuery api, we remove * from the arn, otherwise it throws an error
|
||||
arn := strings.TrimSuffix(lg.Arn, "*")
|
||||
logGroupIdentifiers = append(logGroupIdentifiers, arn)
|
||||
}
|
||||
startQueryInput.LogGroupIdentifiers = logGroupIdentifiers
|
||||
useLogGroupIdentifiers = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !useLogGroupIdentifiers {
|
||||
// even though logsQuery.LogGroupNames is deprecated, we still need to support it for backwards compatibility and alert queries
|
||||
startQueryInput.LogGroupNames = append([]string(nil), logsQuery.LogGroupNames...)
|
||||
if len(startQueryInput.LogGroupNames) == 0 && logGroupsFromQuery {
|
||||
// deduplicate log group names because we only deduplicate log groups by their ARNs instead of their names when the query is created
|
||||
seenLogGroupNames := make(map[string]struct{}, len(logsQuery.LogGroups))
|
||||
for _, lg := range logsQuery.LogGroups {
|
||||
if lg.Name == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenLogGroupNames[lg.Name]; exists {
|
||||
continue
|
||||
}
|
||||
seenLogGroupNames[lg.Name] = struct{}{}
|
||||
startQueryInput.LogGroupNames = append(startQueryInput.LogGroupNames, lg.Name)
|
||||
}
|
||||
}
|
||||
startQueryInput.LogGroupIdentifiers = logGroupIdentifiers
|
||||
} else {
|
||||
// even though log group names are being phased out, we still need to support them for backwards compatibility and alert queries
|
||||
startQueryInput.LogGroupNames = logsQuery.LogGroupNames
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -445,7 +445,9 @@ func Test_executeStartQuery(t *testing.T) {
|
||||
|
||||
t.Run("attaches logGroupIdentifiers if the crossAccount feature is enabled", func(t *testing.T) {
|
||||
cli = fakeCWLogsClient{}
|
||||
ds := newTestDatasource()
|
||||
ds := newTestDatasource(func(ds *DataSource) {
|
||||
ds.monitoringAccountCache.Store("us-east-1", true)
|
||||
})
|
||||
|
||||
_, err := ds.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{
|
||||
PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}},
|
||||
@@ -459,7 +461,8 @@ func Test_executeStartQuery(t *testing.T) {
|
||||
"limit": 12,
|
||||
"queryLanguage": "CWLI",
|
||||
"queryString":"fields @message",
|
||||
"logGroups":[{"arn": "fakeARN"}]
|
||||
"logGroups":[{"arn": "fakeARN"}],
|
||||
"region": "us-east-1"
|
||||
}`),
|
||||
},
|
||||
},
|
||||
@@ -480,7 +483,9 @@ func Test_executeStartQuery(t *testing.T) {
|
||||
|
||||
t.Run("attaches logGroupIdentifiers if the crossAccount feature is enabled and strips out trailing *", func(t *testing.T) {
|
||||
cli = fakeCWLogsClient{}
|
||||
ds := newTestDatasource()
|
||||
ds := newTestDatasource(func(ds *DataSource) {
|
||||
ds.monitoringAccountCache.Store("us-east-1", true)
|
||||
})
|
||||
|
||||
_, err := ds.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &backend.QueryDataRequest{
|
||||
PluginContext: backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{}},
|
||||
@@ -493,7 +498,8 @@ func Test_executeStartQuery(t *testing.T) {
|
||||
"subtype": "StartQuery",
|
||||
"limit": 12,
|
||||
"queryString":"fields @message",
|
||||
"logGroups":[{"arn": "*fake**ARN*"}]
|
||||
"logGroups":[{"arn": "*fake**ARN*"}],
|
||||
"region": "us-east-1"
|
||||
}`),
|
||||
},
|
||||
},
|
||||
@@ -512,6 +518,44 @@ func Test_executeStartQuery(t *testing.T) {
|
||||
}, cli.calls.startQuery)
|
||||
})
|
||||
|
||||
t.Run("queries by LogGroupNames on StartQueryInput when queried region is not a monitoring account region for the data source", func(t *testing.T) {
|
||||
cli = fakeCWLogsClient{}
|
||||
ds := newTestDatasource(func(ds *DataSource) {
|
||||
// note that the query's region is set to us-east-2, but the data source is only a monitoring account in us-east-1 so it should query by LogGroupNames
|
||||
ds.monitoringAccountCache.Store("us-east-1", true)
|
||||
})
|
||||
|
||||
_, err := ds.QueryData(contextWithFeaturesEnabled(features.FlagCloudWatchCrossAccountQuerying), &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":[{"arn": "arn:aws:logs:us-east-1:123456789012:log-group:group","name":"/log-group"}],
|
||||
"region": "us-east-2"
|
||||
}`),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*cloudwatchlogs.StartQueryInput{
|
||||
{
|
||||
StartTime: aws.Int64(0),
|
||||
EndTime: aws.Int64(1),
|
||||
Limit: aws.Int32(12),
|
||||
QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"),
|
||||
LogGroupNames: []string{"/log-group"},
|
||||
QueryLanguage: cloudwatchlogstypes.QueryLanguageCwli,
|
||||
},
|
||||
}, cli.calls.startQuery)
|
||||
})
|
||||
|
||||
t.Run("uses LogGroupNames if the cross account feature flag is not enabled, and log group names is present", func(t *testing.T) {
|
||||
cli = fakeCWLogsClient{}
|
||||
ds := newTestDatasource()
|
||||
@@ -545,6 +589,42 @@ func Test_executeStartQuery(t *testing.T) {
|
||||
}, cli.calls.startQuery)
|
||||
})
|
||||
|
||||
t.Run("deduplicates log group names when derived from logGroups", func(t *testing.T) {
|
||||
cli = fakeCWLogsClient{}
|
||||
ds := newTestDatasource()
|
||||
|
||||
_, err := ds.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":[
|
||||
{"arn": "arn:aws:logs:us-east-1:123456789012:log-group:group1","name":"/log-group"},
|
||||
{"arn": "arn:aws:logs:us-east-1:123456789012:log-group:group2","name":"/log-group"}
|
||||
]
|
||||
}`),
|
||||
},
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*cloudwatchlogs.StartQueryInput{
|
||||
{
|
||||
StartTime: aws.Int64(0),
|
||||
EndTime: aws.Int64(1),
|
||||
Limit: aws.Int32(12),
|
||||
QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"),
|
||||
LogGroupNames: []string{"/log-group"},
|
||||
QueryLanguage: cloudwatchlogstypes.QueryLanguageCwli,
|
||||
},
|
||||
}, cli.calls.startQuery)
|
||||
})
|
||||
|
||||
t.Run("ignores logGroups if feature flag is disabled even if logGroupNames is not present", func(t *testing.T) {
|
||||
cli = fakeCWLogsClient{}
|
||||
ds := newTestDatasource()
|
||||
@@ -600,12 +680,12 @@ func Test_executeStartQuery(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*cloudwatchlogs.StartQueryInput{
|
||||
{
|
||||
StartTime: aws.Int64(0),
|
||||
EndTime: aws.Int64(1),
|
||||
Limit: aws.Int32(12),
|
||||
QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"),
|
||||
LogGroupIdentifiers: []string{"*fake**ARN"},
|
||||
QueryLanguage: cloudwatchlogstypes.QueryLanguageCwli,
|
||||
StartTime: aws.Int64(0),
|
||||
EndTime: aws.Int64(1),
|
||||
Limit: aws.Int32(12),
|
||||
QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"),
|
||||
LogGroupNames: []string{"/log-group"},
|
||||
QueryLanguage: cloudwatchlogstypes.QueryLanguageCwli,
|
||||
},
|
||||
}, cli.calls.startQuery)
|
||||
})
|
||||
|
||||
@@ -263,8 +263,8 @@ describe('datasource', () => {
|
||||
expect(queryMock.mock.calls[0][0].targets[0]).toMatchObject({
|
||||
queryString: 'fields templatedField',
|
||||
logGroups: [
|
||||
{ name: 'templatedGroup-arn-1', arn: 'templatedGroup-arn-1' },
|
||||
{ name: 'templatedGroup-arn-2', arn: 'templatedGroup-arn-2' },
|
||||
{ name: 'templatedGroup-1', arn: 'templatedGroup-arn-1' },
|
||||
{ name: 'templatedGroup-2', arn: 'templatedGroup-arn-2' },
|
||||
],
|
||||
logGroupNames: ['/some/group'],
|
||||
region: 'templatedRegion',
|
||||
@@ -394,8 +394,9 @@ describe('datasource', () => {
|
||||
|
||||
expect(templateService.replace).toHaveBeenNthCalledWith(1, '$regionVar', {});
|
||||
expect(templateService.replace).toHaveBeenNthCalledWith(2, '$groups', {}, 'pipe');
|
||||
expect(templateService.replace).toHaveBeenNthCalledWith(3, '$expressionVar', {}, undefined);
|
||||
expect(templateService.replace).toHaveBeenCalledTimes(3);
|
||||
expect(templateService.replace).toHaveBeenNthCalledWith(3, '$groups', {}, 'text');
|
||||
expect(templateService.replace).toHaveBeenNthCalledWith(4, '$expressionVar', {}, undefined);
|
||||
expect(templateService.replace).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('should replace correct variables in CloudWatchMetricsQuery', () => {
|
||||
|
||||
+58
-1
@@ -10,7 +10,7 @@ import {
|
||||
LogRowModel,
|
||||
} from '@grafana/data';
|
||||
|
||||
import { regionVariable } from '../mocks/CloudWatchDataSource';
|
||||
import { logGroupNamesVariable, regionVariable } from '../mocks/CloudWatchDataSource';
|
||||
import { setupMockedLogsQueryRunner } from '../mocks/LogsQueryRunner';
|
||||
import { LogsRequestMock } from '../mocks/Request';
|
||||
import { validLogsQuery } from '../mocks/queries';
|
||||
@@ -28,6 +28,63 @@ describe('CloudWatchLogsQueryRunner', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('interpolateLogsQueryVariables', () => {
|
||||
it('returns logGroups with arn and name values sourced from the log group template variable', () => {
|
||||
const { runner } = setupMockedLogsQueryRunner({ variables: [logGroupNamesVariable] });
|
||||
|
||||
const query: CloudWatchLogsQuery = {
|
||||
...validLogsQuery,
|
||||
logGroups: [{ arn: '$groups', name: '$groups' }],
|
||||
};
|
||||
|
||||
const { logGroups } = runner.interpolateLogsQueryVariables(query, {});
|
||||
|
||||
expect(logGroups).toEqual([
|
||||
{ arn: 'templatedGroup-arn-1', name: 'templatedGroup-1' },
|
||||
{ arn: 'templatedGroup-arn-2', name: 'templatedGroup-2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters out duplicate log group arns when query already includes an expanded value', () => {
|
||||
const { runner } = setupMockedLogsQueryRunner({ variables: [logGroupNamesVariable] });
|
||||
|
||||
const query: CloudWatchLogsQuery = {
|
||||
...validLogsQuery,
|
||||
logGroups: [
|
||||
{ arn: 'templatedGroup-arn-1', name: 'existing-group-name' },
|
||||
{ arn: '$groups', name: '$groups' },
|
||||
],
|
||||
};
|
||||
|
||||
const { logGroups } = runner.interpolateLogsQueryVariables(query, {});
|
||||
|
||||
expect(logGroups).toEqual([
|
||||
{ arn: 'templatedGroup-arn-1', name: 'existing-group-name' },
|
||||
{ arn: 'templatedGroup-arn-2', name: 'templatedGroup-2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps log groups with duplicate names as long as arns are unique', () => {
|
||||
const { runner } = setupMockedLogsQueryRunner({ variables: [logGroupNamesVariable] });
|
||||
|
||||
const query: CloudWatchLogsQuery = {
|
||||
...validLogsQuery,
|
||||
logGroups: [
|
||||
{ arn: 'arn-1', name: 'templatedGroup-1' },
|
||||
{ arn: '$groups', name: '$groups' },
|
||||
],
|
||||
};
|
||||
|
||||
const { logGroups } = runner.interpolateLogsQueryVariables(query, {});
|
||||
|
||||
expect(logGroups).toEqual([
|
||||
{ arn: 'arn-1', name: 'templatedGroup-1' },
|
||||
{ arn: 'templatedGroup-arn-1', name: 'templatedGroup-1' },
|
||||
{ arn: 'templatedGroup-arn-2', name: 'templatedGroup-2' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogRowContext', () => {
|
||||
it('replaces parameters correctly in the query', async () => {
|
||||
const { runner, queryMock } = setupMockedLogsQueryRunner({ variables: [regionVariable] });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { set, uniq } from 'lodash';
|
||||
import { set, uniq, uniqBy } from 'lodash';
|
||||
import {
|
||||
concatMap,
|
||||
finalize,
|
||||
@@ -253,9 +253,19 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
|
||||
(query.logGroups || this.instanceSettings.jsonData.logGroups || []).map((lg) => lg.arn),
|
||||
scopedVars
|
||||
);
|
||||
const interpolatedLogGroupNames = interpolateStringArrayUsingSingleOrMultiValuedVariable(
|
||||
this.templateSrv,
|
||||
(query.logGroups || this.instanceSettings.jsonData.logGroups || []).map((lg) => lg.name),
|
||||
scopedVars,
|
||||
'text'
|
||||
);
|
||||
const interpolatedLogGroups = interpolatedLogGroupArns.map((arn, index) => ({
|
||||
arn,
|
||||
name: interpolatedLogGroupNames[index] ?? arn,
|
||||
}));
|
||||
|
||||
// need to support legacy format variables too
|
||||
const interpolatedLogGroupNames = interpolateStringArrayUsingSingleOrMultiValuedVariable(
|
||||
const interpolatedLegacyLogGroupNames = interpolateStringArrayUsingSingleOrMultiValuedVariable(
|
||||
this.templateSrv,
|
||||
query.logGroupNames || this.instanceSettings.jsonData.defaultLogGroups || [],
|
||||
scopedVars,
|
||||
@@ -264,8 +274,8 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
|
||||
|
||||
// if a log group template variable expands to log group that has already been selected in the log group picker, we need to remove duplicates.
|
||||
// Otherwise the StartLogQuery API will return a permission error
|
||||
const logGroups = uniq(interpolatedLogGroupArns).map((arn) => ({ arn, name: arn }));
|
||||
const logGroupNames = uniq(interpolatedLogGroupNames);
|
||||
const logGroups = uniqBy(interpolatedLogGroups, 'arn');
|
||||
const logGroupNames = uniq(interpolatedLegacyLogGroupNames);
|
||||
|
||||
const logsSQLCustomerFormatter = (value: unknown, model: Partial<CustomFormatterVariable>) => {
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user