Alerting: Handle regex matchers with flags (#95883)

* handle regex matchers with flags

* add test in alertmanager.test.ts

* refactor to use the same matching functions

* lint

* lint again

* move matchers functions to matchers.ts

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Sonia Aguilar
2024-11-06 13:40:55 +02:00
committed by GitHub
co-authored by Gilles De Mey
parent f8ae71e458
commit 3c1a5ab439
7 changed files with 124 additions and 101 deletions
@@ -1,4 +1,4 @@
import { render, screen, waitFor, within, userEvent } from 'test/test-utils';
import { render, screen, userEvent, waitFor, within } from 'test/test-utils';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { AccessControlAction } from 'app/types/accessControl';
@@ -463,4 +463,27 @@ describe('NotificationPreviewByAlertmanager', () => {
expect(screen.queryByText(/regexfield/i)).not.toBeInTheDocument();
});
});
it('matches regex with flags', async () => {
const potentialInstances: Labels[] = [{ regexfield: 'baaaaaaah' }];
mockApi(server).getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, (amConfigBuilder) =>
amConfigBuilder
.addReceivers((b) => b.withName('email'))
.withRoute((routeBuilder) =>
routeBuilder
.withReceiver('email')
.addRoute((rb) => rb.withReceiver('email').addMatcher('regexfield', MatcherOperator.regex, '(?i)BA.*h'))
)
);
render(
<NotificationPreviewByAlertManager
alertManagerSource={grafanaAlertManagerDataSource}
potentialInstances={potentialInstances}
onlyOneAM={true}
/>
);
expect(await screen.findByText(/regexfield/i)).toBeInTheDocument();
});
});
@@ -1,7 +1,7 @@
import { Matcher, MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types';
import { Labels } from 'app/types/unified-alerting-dto';
import { labelsMatchMatchers, removeTimeIntervalFromRoute, matchersToString } from './alertmanager';
import { labelsMatchMatchers, matchersToString, removeTimeIntervalFromRoute } from './alertmanager';
import { parseMatcher, parsePromQLStyleMatcherLooseSafe } from './matchers';
describe('Alertmanager utils', () => {
@@ -92,6 +92,15 @@ describe('Alertmanager utils', () => {
const matchers = parsePromQLStyleMatcherLooseSafe('foo!=bazz,bar=~ba.+');
expect(labelsMatchMatchers(labels, matchers)).toBe(true);
});
it('should match when using flags and different operators', () => {
const labels: Labels = {
foo: 'bar',
bar: 'bazz',
email: 'admin@grafana.com',
};
const matchers = parsePromQLStyleMatcherLooseSafe('foo!=bazz,bar=~(?i)Ba.+');
expect(labelsMatchMatchers(labels, matchers)).toBe(true);
});
});
describe('removeMuteTimingFromRoute', () => {
@@ -16,7 +16,8 @@ import { MatcherFieldValue } from '../types/silence-form';
import { getAllDataSources } from './config';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource';
import { MatcherFormatter, parsePromQLStyleMatcherLooseSafe, unquoteWithUnescape } from './matchers';
import { objectLabelsToArray } from './labels';
import { MatcherFormatter, matchLabelsSet, parsePromQLStyleMatcherLooseSafe, unquoteWithUnescape } from './matchers';
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
// add default receiver if it does not exist
@@ -125,26 +126,10 @@ export function matcherToObjectMatcher(matcher: Matcher): ObjectMatcher {
}
export function labelsMatchMatchers(labels: Labels, matchers: Matcher[]): boolean {
return matchers.every(({ name, value, isRegex, isEqual }) => {
return Object.entries(labels).some(([labelKey, labelValue]) => {
const nameMatches = name === labelKey;
let valueMatches;
if (isEqual && !isRegex) {
valueMatches = value === labelValue;
}
if (!isEqual && !isRegex) {
valueMatches = value !== labelValue;
}
if (isEqual && isRegex) {
valueMatches = new RegExp(value).test(labelValue);
}
if (!isEqual && isRegex) {
valueMatches = !new RegExp(value).test(labelValue);
}
const labelsArray = objectLabelsToArray(labels);
const objectMatchers = matchers.map(matcherToObjectMatcher);
return nameMatches && valueMatches;
});
});
return matchLabelsSet(objectMatchers, labelsArray);
}
export function combineMatcherStrings(...matcherStrings: string[]): string {
@@ -8,7 +8,7 @@ export function labelsToTags(labels: Labels) {
}
export function objectLabelsToArray(labels: Labels): Label[] {
return Object.entries(labels).map(([label, value]) => [label, value]);
return Object.entries(labels);
}
export function arrayLabelsToObject(labels: Label[]): Labels {
@@ -7,6 +7,7 @@
import { compact, uniqBy } from 'lodash';
import { parseFlags } from '@grafana/data';
import { Matcher, MatcherOperator, ObjectMatcher, Route } from 'app/plugins/datasource/alertmanager/types';
import { Labels } from '../../../../types/unified-alerting-dto';
@@ -240,6 +241,81 @@ function matcherToOperator(matcher: Matcher): MatcherOperator {
}
}
// Compare set of matchers to set of label
export function matchLabelsSet(matchers: ObjectMatcher[], labels: Label[]): boolean {
for (const matcher of matchers) {
if (!isLabelMatchInSet(matcher, labels)) {
return false;
}
}
return true;
}
type OperatorPredicate = (labelValue: string, matcherValue: string) => boolean;
const OperatorFunctions: Record<MatcherOperator, OperatorPredicate> = {
[MatcherOperator.equal]: (lv, mv) => lv === mv,
[MatcherOperator.notEqual]: (lv, mv) => lv !== mv,
// At the time of writing, Alertmanager compiles to another (anchored) Regular Expression,
// so we should also anchor our UI matches for consistency with this behaviour
// https://github.com/prometheus/alertmanager/blob/fd37ce9c95898ca68be1ab4d4529517174b73c33/pkg/labels/matcher.go#L69
[MatcherOperator.regex]: (lv, mv) => {
const valueWithFlagsParsed = parseFlags(`^(?:${mv})$`);
const re = new RegExp(valueWithFlagsParsed.cleaned, valueWithFlagsParsed.flags);
return re.test(lv);
},
[MatcherOperator.notRegex]: (lv, mv) => {
const valueWithFlagsParsed = parseFlags(`^(?:${mv})$`);
const re = new RegExp(valueWithFlagsParsed.cleaned, valueWithFlagsParsed.flags);
return !re.test(lv);
},
};
function isLabelMatchInSet(matcher: ObjectMatcher, labels: Label[]): boolean {
const [matcherKey, operator, matcherValue] = matcher;
let labelValue = ''; // matchers that have no labels are treated as empty string label values
const labelForMatcher = Object.fromEntries(labels)[matcherKey];
if (labelForMatcher) {
labelValue = labelForMatcher;
}
const matchFunction = OperatorFunctions[operator];
if (!matchFunction) {
throw new Error(`no such operator: ${operator}`);
}
try {
// This can throw because the regex operators use the JavaScript regex engine
// and "new RegExp()" throws on invalid regular expressions.
//
// This is usually a user-error (because matcher values are taken from user input)
// but we're still logging this as a warning because it _might_ be a programmer error.
return matchFunction(labelValue, matcherValue);
} catch (err) {
console.warn(err);
return false;
}
}
// ⚠️ DO NOT USE THIS FUNCTION FOR ROUTE SELECTION ALGORITHM
// for route selection algorithm, always compare a single matcher to the entire label set
// see "matchLabelsSet"
export function isLabelMatch(matcher: ObjectMatcher, label: Label): boolean {
const [labelKey, labelValue] = label;
const [matcherKey, operator, matcherValue] = matcher;
if (labelKey !== matcherKey) {
return false;
}
const matchFunction = OperatorFunctions[operator];
if (!matchFunction) {
throw new Error(`no such operator: ${operator}`);
}
return matchFunction(labelValue, matcherValue);
}
export type MatcherFormatter = keyof typeof matcherFormatter;
export type Label = [string, string];
@@ -485,6 +485,11 @@ describe('matchLabels', () => {
const result = matchLabels([['foo', MatcherOperator.regex, '.*bar.*']], [['foo', 'barbarbar']]);
expect(result.matches).toEqual(true);
});
it('does match regular expressions with flags', () => {
const result = matchLabels([['foo', MatcherOperator.regex, '(?i).*BAr.*']], [['foo', 'barbarbar']]);
expect(result.matches).toEqual(true);
});
});
describe('unquoteRouteMatchers', () => {
@@ -1,15 +1,9 @@
import { isArray, pick, reduce } from 'lodash';
import {
AlertmanagerGroup,
MatcherOperator,
ObjectMatcher,
Route,
RouteWithID,
} from 'app/plugins/datasource/alertmanager/types';
import { AlertmanagerGroup, ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { Labels } from 'app/types/unified-alerting-dto';
import { Label, normalizeMatchers, unquoteWithUnescape } from './matchers';
import { isLabelMatch, Label, matchLabelsSet, normalizeMatchers, unquoteWithUnescape } from './matchers';
// If a policy has no matchers it still can be a match, hence matchers can be empty and match can be true
// So we cannot use null as an indicator of no match
@@ -52,16 +46,6 @@ export function matchLabels(matchers: ObjectMatcher[], labels: Label[]): Matchin
return { matches, labelsMatch };
}
// Compare set of matchers to set of label
export function matchLabelsSet(matchers: ObjectMatcher[], labels: Label[]): boolean {
for (const matcher of matchers) {
if (!isLabelMatchInSet(matcher, labels)) {
return false;
}
}
return true;
}
export interface AlertInstanceMatch {
instance: Labels;
labelsMatch: LabelsMatch;
@@ -228,59 +212,6 @@ export function computeInheritedTree<T extends Route>(parent: T): T {
};
}
type OperatorPredicate = (labelValue: string, matcherValue: string) => boolean;
const OperatorFunctions: Record<MatcherOperator, OperatorPredicate> = {
[MatcherOperator.equal]: (lv, mv) => lv === mv,
[MatcherOperator.notEqual]: (lv, mv) => lv !== mv,
// At the time of writing, Alertmanager compiles to another (anchored) Regular Expression,
// so we should also anchor our UI matches for consistency with this behaviour
// https://github.com/prometheus/alertmanager/blob/fd37ce9c95898ca68be1ab4d4529517174b73c33/pkg/labels/matcher.go#L69
[MatcherOperator.regex]: (lv, mv) => {
const re = new RegExp(`^(?:${mv})$`);
return re.test(lv);
},
[MatcherOperator.notRegex]: (lv, mv) => {
const re = new RegExp(`^(?:${mv})$`);
return !re.test(lv);
},
};
function isLabelMatchInSet(matcher: ObjectMatcher, labels: Label[]): boolean {
const [matcherKey, operator, matcherValue] = matcher;
let labelValue = ''; // matchers that have no labels are treated as empty string label values
const labelForMatcher = Object.fromEntries(labels)[matcherKey];
if (labelForMatcher) {
labelValue = labelForMatcher;
}
const matchFunction = OperatorFunctions[operator];
if (!matchFunction) {
throw new Error(`no such operator: ${operator}`);
}
return matchFunction(labelValue, matcherValue);
}
// ⚠️ DO NOT USE THIS FUNCTION FOR ROUTE SELECTION ALGORITHM
// for route selection algorithm, always compare a single matcher to the entire label set
// see "matchLabelsSet"
function isLabelMatch(matcher: ObjectMatcher, label: Label): boolean {
const [labelKey, labelValue] = label;
const [matcherKey, operator, matcherValue] = matcher;
if (labelKey !== matcherKey) {
return false;
}
const matchFunction = OperatorFunctions[operator];
if (!matchFunction) {
throw new Error(`no such operator: ${operator}`);
}
return matchFunction(labelValue, matcherValue);
}
// recursive function to rename receivers in all routes (notification policies)
function renameReceiverInRoute(route: Route, oldName: string, newName: string) {
const updated: Route = {
@@ -298,10 +229,4 @@ function renameReceiverInRoute(route: Route, oldName: string, newName: string) {
return updated;
}
export {
findMatchingAlertGroups,
findMatchingRoutes,
getInheritedProperties,
isLabelMatchInSet,
renameReceiverInRoute,
};
export { findMatchingAlertGroups, findMatchingRoutes, getInheritedProperties, renameReceiverInRoute };