[Response Ops][Maintenance Window] Fix Maintenance Window Wildcard Scoped Queries (#194777)

## Summary

Issue: https://github.com/elastic/sdh-kibana/issues/4923

Fixes maintenance window scoped query using wildcards by injecting the
`analyze_wildcard` property to the DSL used to determine which alerts
should be associated with the maintenance window.

Also fixes the update route to correctly take into account the user's
`allowLeadingWildcard` flag. It was implemented for the create route but
not the update route.

Fixes: https://github.com/elastic/kibana/issues/194763

### To test:
1. Install sample data:

![image](https://github.com/user-attachments/assets/4be72fc8-e4ab-47a3-b5db-48f97b1827ae)

2. Create a maintenance window with the following scoped query: 

![image](https://github.com/user-attachments/assets/e2d37fd0-b957-4e76-bea3-8d954651c557)

3. Create a ES query rule and trigger actions:

![image](https://github.com/user-attachments/assets/551f5145-9ab7-48c4-a48e-e674b4f0509a)

4. Assert the `maintenance_window_id` on the 4 alerts are set

![image](https://github.com/user-attachments/assets/7ace95d3-d992-4305-a564-cf3004c9ae9e)


### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios)

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Jiawei Wu 2024-10-26 18:47:29 +09:00 committed by GitHub
parent 95ed9ad9fd
commit 7ad937db57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 306 additions and 7 deletions

View file

@ -39,6 +39,7 @@ import {
import { SummarizedAlertsChunk, ScopedQueryAlerts } from '../..';
import { FormatAlert } from '../../types';
import { expandFlattenedAlert } from './format_alert';
import { injectAnalyzeWildcard } from './inject_analyze_wildcard';
const MAX_ALERT_DOCS_TO_RETURN = 100;
enum AlertTypes {
@ -310,9 +311,14 @@ export const getQueryByScopedQueries = ({
return;
}
const scopedQueryFilter = generateAlertsFilterDSL({
query: scopedQuery as AlertsFilter['query'],
})[0] as { bool: BoolQuery };
const scopedQueryFilter = generateAlertsFilterDSL(
{
query: scopedQuery as AlertsFilter['query'],
},
{
analyzeWildcard: true,
}
)[0] as { bool: BoolQuery };
aggs[id] = {
filter: {
@ -324,6 +330,7 @@ export const getQueryByScopedQueries = ({
aggs: {
alertId: {
top_hits: {
size: MAX_ALERT_DOCS_TO_RETURN,
_source: {
includes: [ALERT_UUID],
},
@ -340,11 +347,19 @@ export const getQueryByScopedQueries = ({
};
};
const generateAlertsFilterDSL = (alertsFilter: AlertsFilter): QueryDslQueryContainer[] => {
const generateAlertsFilterDSL = (
alertsFilter: AlertsFilter,
options?: { analyzeWildcard?: boolean }
): QueryDslQueryContainer[] => {
const filter: QueryDslQueryContainer[] = [];
const { analyzeWildcard = false } = options || {};
if (alertsFilter.query) {
filter.push(JSON.parse(alertsFilter.query.dsl!));
const parsedQuery = JSON.parse(alertsFilter.query.dsl!);
if (analyzeWildcard) {
injectAnalyzeWildcard(parsedQuery);
}
filter.push(parsedQuery);
}
if (alertsFilter.timeframe) {
filter.push(

View file

@ -0,0 +1,169 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { injectAnalyzeWildcard } from './inject_analyze_wildcard';
const getQuery = (query?: string) => {
return {
bool: {
must: [],
filter: [
{
bool: {
filter: [
{
bool: {
should: [
{
query_string: {
fields: ['kibana.alert.instance.id'],
query: query || '*elastic*',
},
},
],
minimum_should_match: 1,
},
},
{
bool: {
should: [
{
match: {
'kibana.alert.action_group': 'test',
},
},
],
minimum_should_match: 1,
},
},
],
},
},
],
should: [],
must_not: [
{
match_phrase: {
_id: 'assdasdasd',
},
},
],
},
};
};
describe('injectAnalyzeWildcard', () => {
test('should inject analyze_wildcard field', () => {
const query = getQuery();
injectAnalyzeWildcard(query);
expect(query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"query_string": Object {
"analyze_wildcard": true,
"fields": Array [
"kibana.alert.instance.id",
],
"query": "*elastic*",
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"kibana.alert.action_group": "test",
},
},
],
},
},
],
},
},
],
"must": Array [],
"must_not": Array [
Object {
"match_phrase": Object {
"_id": "assdasdasd",
},
},
],
"should": Array [],
},
}
`);
});
test('should not inject analyze_wildcard if the query does not contain *', () => {
const query = getQuery('test');
injectAnalyzeWildcard(query);
expect(query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"query_string": Object {
"fields": Array [
"kibana.alert.instance.id",
],
"query": "test",
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"kibana.alert.action_group": "test",
},
},
],
},
},
],
},
},
],
"must": Array [],
"must_not": Array [
Object {
"match_phrase": Object {
"_id": "assdasdasd",
},
},
],
"should": Array [],
},
}
`);
});
});

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
export const injectAnalyzeWildcard = (query: QueryDslQueryContainer): void => {
if (!query) {
return;
}
if (Array.isArray(query)) {
return query.forEach((child) => injectAnalyzeWildcard(child));
}
if (typeof query === 'object') {
Object.entries(query).forEach(([key, value]) => {
if (key !== 'query_string') {
return injectAnalyzeWildcard(value);
}
if (typeof value.query === 'string' && value.query.includes('*')) {
value.analyze_wildcard = true;
}
});
}
};

View file

@ -135,6 +135,21 @@ describe('MaintenanceWindowClient - update', () => {
eventEndTime: '2023-03-05T01:00:00.000Z',
})
);
expect(uiSettings.get).toHaveBeenCalledTimes(3);
expect(uiSettings.get.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"query:allowLeadingWildcards",
],
Array [
"query:queryString:options",
],
Array [
"courier:ignoreFilterIfFieldNotInIndex",
],
]
`);
});
it('should not regenerate all events if rrule and duration did not change', async () => {

View file

@ -10,6 +10,7 @@ import Boom from '@hapi/boom';
import { buildEsQuery, Filter } from '@kbn/es-query';
import type { MaintenanceWindowClientContext } from '../../../../../common';
import { getScopedQueryErrorMessage } from '../../../../../common';
import { getEsQueryConfig } from '../../../../lib/get_es_query_config';
import type { MaintenanceWindow } from '../../types';
import {
generateMaintenanceWindowEvents,
@ -45,9 +46,10 @@ async function updateWithOCC(
context: MaintenanceWindowClientContext,
params: UpdateMaintenanceWindowParams
): Promise<MaintenanceWindow> {
const { savedObjectsClient, getModificationMetadata, logger } = context;
const { savedObjectsClient, getModificationMetadata, logger, uiSettings } = context;
const { id, data } = params;
const { title, enabled, duration, rRule, categoryIds, scopedQuery } = data;
const esQueryConfig = await getEsQueryConfig(uiSettings);
try {
updateMaintenanceWindowParamsSchema.validate(params);
@ -62,7 +64,8 @@ async function updateWithOCC(
buildEsQuery(
undefined,
[{ query: scopedQuery.kql, language: 'kuery' }],
scopedQuery.filters as Filter[]
scopedQuery.filters as Filter[],
esQueryConfig
)
);
scopedQueryWithGeneratedValue = {

View file

@ -245,5 +245,72 @@ export default function maintenanceWindowScopedQueryTests({ getService }: FtrPro
retry,
});
});
it('should associate alerts when scoped query contains wildcards', async () => {
await createMaintenanceWindow({
supertest,
objectRemover,
overwrites: {
scoped_query: {
kql: 'kibana.alert.rule.name: *test*',
filters: [],
},
category_ids: ['management'],
},
});
// Create action and rule
const action = await await createAction({
supertest,
objectRemover,
});
const { body: rule } = await supertestWithoutAuth
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
name: 'rule-test-rule',
rule_type_id: 'test.always-firing-alert-as-data',
schedule: { interval: '24h' },
tags: ['test'],
throttle: undefined,
notify_when: 'onActiveAlert',
params: {
index: alertAsDataIndex,
reference: 'test',
},
actions: [
{
id: action.id,
group: 'default',
params: {},
},
{
id: action.id,
group: 'recovered',
params: {},
},
],
})
)
.expect(200);
objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting');
// Run the first time - active
await getRuleEvents({
id: rule.id,
activeInstance: 2,
retry,
getService,
});
await expectNoActionsFired({
id: rule.id,
supertest,
retry,
});
});
});
}