mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Apply negative matchers for route matching (#77292)
This commit is contained in:
parent
7430cce0e0
commit
05b6f7f396
@ -32,17 +32,13 @@ export const routeGroupsMatcher = {
|
||||
|
||||
instancesToMatch.forEach((instance) => {
|
||||
const matchingRoutes = findMatchingRoutes(normalizedRootRoute, Object.entries(instance));
|
||||
matchingRoutes.forEach(({ route, details, labelsMatch }) => {
|
||||
// Only to convert Label[] to Labels[] - needs better approach
|
||||
const matchDetails = new Map(
|
||||
Array.from(details.entries()).map(([matcher, labels]) => [matcher, Object.fromEntries(labels)])
|
||||
);
|
||||
|
||||
matchingRoutes.forEach(({ route, labelsMatch }) => {
|
||||
const currentRoute = result.get(route.id);
|
||||
|
||||
if (currentRoute) {
|
||||
currentRoute.push({ instance, matchDetails, labelsMatch });
|
||||
currentRoute.push({ instance, labelsMatch });
|
||||
} else {
|
||||
result.set(route.id, [{ instance, matchDetails, labelsMatch }]);
|
||||
result.set(route.id, [{ instance, labelsMatch }]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,56 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`matchLabels should match with non-equal matchers 1`] = `
|
||||
Map {
|
||||
[
|
||||
"team",
|
||||
"operations",
|
||||
] => {
|
||||
"match": true,
|
||||
"matcher": [
|
||||
"team",
|
||||
"=",
|
||||
"operations",
|
||||
],
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`matchLabels should match with non-matching matchers 1`] = `
|
||||
Map {
|
||||
[
|
||||
"team",
|
||||
"operations",
|
||||
] => {
|
||||
"match": true,
|
||||
"matcher": [
|
||||
"team",
|
||||
"=",
|
||||
"operations",
|
||||
],
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`matchLabels should not match with a set of matchers 1`] = `
|
||||
Map {
|
||||
[
|
||||
"team",
|
||||
"operations",
|
||||
] => {
|
||||
"match": true,
|
||||
"matcher": [
|
||||
"team",
|
||||
"=",
|
||||
"operations",
|
||||
],
|
||||
},
|
||||
[
|
||||
"foo",
|
||||
"bar",
|
||||
] => {
|
||||
"match": false,
|
||||
"matcher": null,
|
||||
},
|
||||
}
|
||||
`;
|
@ -109,34 +109,3 @@ export const normalizeMatchers = (route: Route): ObjectMatcher[] => {
|
||||
};
|
||||
|
||||
export type Label = [string, string];
|
||||
type OperatorPredicate = (labelValue: string, matcherValue: string) => boolean;
|
||||
const OperatorFunctions: Record<MatcherOperator, OperatorPredicate> = {
|
||||
[MatcherOperator.equal]: (lv, mv) => lv === mv,
|
||||
[MatcherOperator.notEqual]: (lv, mv) => lv !== mv,
|
||||
[MatcherOperator.regex]: (lv, mv) => new RegExp(mv).test(lv),
|
||||
[MatcherOperator.notRegex]: (lv, mv) => !new RegExp(mv).test(lv),
|
||||
};
|
||||
|
||||
function isLabelMatch(matcher: ObjectMatcher, label: Label) {
|
||||
const [labelKey, labelValue] = label;
|
||||
const [matcherKey, operator, matcherValue] = matcher;
|
||||
|
||||
// not interested, keys don't match
|
||||
if (labelKey !== matcherKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchFunction = OperatorFunctions[operator];
|
||||
if (!matchFunction) {
|
||||
throw new Error(`no such operator: ${operator}`);
|
||||
}
|
||||
|
||||
return matchFunction(labelValue, matcherValue);
|
||||
}
|
||||
|
||||
// check if every matcher returns "true" for the set of labels
|
||||
export function labelsMatchObjectMatchers(matchers: ObjectMatcher[], labels: Label[]) {
|
||||
return matchers.every((matcher) => {
|
||||
return labels.some((label) => isLabelMatch(matcher, label));
|
||||
});
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
normalizeRoute,
|
||||
getInheritedProperties,
|
||||
computeInheritedTree,
|
||||
matchLabels,
|
||||
} from './notification-policies';
|
||||
|
||||
import 'core-js/stable/structured-clone';
|
||||
@ -22,6 +23,7 @@ describe('findMatchingRoutes', () => {
|
||||
routes: [
|
||||
{
|
||||
receiver: 'A',
|
||||
object_matchers: [['team', MatcherOperator.equal, 'operations']],
|
||||
routes: [
|
||||
{
|
||||
receiver: 'B1',
|
||||
@ -29,10 +31,9 @@ describe('findMatchingRoutes', () => {
|
||||
},
|
||||
{
|
||||
receiver: 'B2',
|
||||
object_matchers: [['region', MatcherOperator.notEqual, 'europe']],
|
||||
object_matchers: [['region', MatcherOperator.equal, 'nasa']],
|
||||
},
|
||||
],
|
||||
object_matchers: [['team', MatcherOperator.equal, 'operations']],
|
||||
},
|
||||
{
|
||||
receiver: 'C',
|
||||
@ -55,6 +56,19 @@ describe('findMatchingRoutes', () => {
|
||||
expect(matches[0].route).toHaveProperty('receiver', 'A');
|
||||
});
|
||||
|
||||
it('should match route with negative matchers', () => {
|
||||
const policiesWithNegative = {
|
||||
...policies,
|
||||
routes: policies.routes?.concat({
|
||||
receiver: 'D',
|
||||
object_matchers: [['name', MatcherOperator.notEqual, 'gilles']],
|
||||
}),
|
||||
};
|
||||
const matches = findMatchingRoutes(policiesWithNegative, [['name', 'konrad']]);
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].route).toHaveProperty('receiver', 'D');
|
||||
});
|
||||
|
||||
it('should match child route of matching parent', () => {
|
||||
const matches = findMatchingRoutes(policies, [
|
||||
['team', 'operations'],
|
||||
@ -378,3 +392,47 @@ describe('normalizeRoute', () => {
|
||||
expect(normalized).not.toHaveProperty('match_re');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchLabels', () => {
|
||||
it('should match with non-matching matchers', () => {
|
||||
const result = matchLabels(
|
||||
[
|
||||
['foo', MatcherOperator.equal, ''],
|
||||
['team', MatcherOperator.equal, 'operations'],
|
||||
],
|
||||
[['team', 'operations']]
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('matches', true);
|
||||
expect(result.labelsMatch).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should match with non-equal matchers', () => {
|
||||
const result = matchLabels(
|
||||
[
|
||||
['foo', MatcherOperator.notEqual, 'bar'],
|
||||
['team', MatcherOperator.equal, 'operations'],
|
||||
],
|
||||
[['team', 'operations']]
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('matches', true);
|
||||
expect(result.labelsMatch).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should not match with a set of matchers', () => {
|
||||
const result = matchLabels(
|
||||
[
|
||||
['foo', MatcherOperator.notEqual, 'bar'],
|
||||
['team', MatcherOperator.equal, 'operations'],
|
||||
],
|
||||
[
|
||||
['team', 'operations'],
|
||||
['foo', 'bar'],
|
||||
]
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('matches', false);
|
||||
expect(result.labelsMatch).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
@ -11,111 +11,84 @@ import { Labels } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { Label, normalizeMatchers } from './matchers';
|
||||
|
||||
type OperatorPredicate = (labelValue: string, matcherValue: string) => boolean;
|
||||
|
||||
const OperatorFunctions: Record<MatcherOperator, OperatorPredicate> = {
|
||||
[MatcherOperator.equal]: (lv, mv) => lv === mv,
|
||||
[MatcherOperator.notEqual]: (lv, mv) => lv !== mv,
|
||||
[MatcherOperator.regex]: (lv, mv) => Boolean(lv.match(new RegExp(mv))),
|
||||
[MatcherOperator.notRegex]: (lv, mv) => !Boolean(lv.match(new RegExp(mv))),
|
||||
};
|
||||
|
||||
function isLabelMatch(matcher: ObjectMatcher, label: Label) {
|
||||
const [labelKey, labelValue] = label;
|
||||
const [matcherKey, operator, matcherValue] = matcher;
|
||||
|
||||
// not interested, keys don't match
|
||||
if (labelKey !== matcherKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchFunction = OperatorFunctions[operator];
|
||||
if (!matchFunction) {
|
||||
throw new Error(`no such operator: ${operator}`);
|
||||
}
|
||||
|
||||
return matchFunction(labelValue, matcherValue);
|
||||
}
|
||||
|
||||
// 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
|
||||
interface LabelMatchResult {
|
||||
match: boolean;
|
||||
matchers: ObjectMatcher[];
|
||||
matcher: ObjectMatcher | null;
|
||||
}
|
||||
|
||||
type LabelsMatch = Map<Label, LabelMatchResult>;
|
||||
|
||||
interface MatchingResult {
|
||||
matches: boolean;
|
||||
details: Map<ObjectMatcher, Label[]>;
|
||||
labelsMatch: Map<Label, LabelMatchResult>;
|
||||
labelsMatch: LabelsMatch;
|
||||
}
|
||||
|
||||
// check if every matcher returns "true" for the set of labels
|
||||
function matchLabels(matchers: ObjectMatcher[], labels: Label[]): MatchingResult {
|
||||
const details = new Map<ObjectMatcher, Label[]>();
|
||||
// returns a match results for given set of matchers (from a policy for instance) and a set of labels
|
||||
export function matchLabels(matchers: ObjectMatcher[], labels: Label[]): MatchingResult {
|
||||
const matches = matchLabelsSet(matchers, labels);
|
||||
|
||||
// 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 empty array of matchers as an indicator of no match
|
||||
const labelsMatch = new Map<Label, { match: boolean; matchers: ObjectMatcher[] }>(
|
||||
labels.map((label) => [label, { match: false, matchers: [] }])
|
||||
);
|
||||
// create initial map of label => match result
|
||||
const labelsMatch: LabelsMatch = new Map(labels.map((label) => [label, { match: false, matcher: null }]));
|
||||
|
||||
const matches = matchers.every((matcher) => {
|
||||
const matchingLabels = labels.filter((label) => isLabelMatch(matcher, label));
|
||||
// for each matcher, check which label it matched for
|
||||
matchers.forEach((matcher) => {
|
||||
const matchingLabel = labels.find((label) => isLabelMatch(matcher, label));
|
||||
|
||||
matchingLabels.forEach((label) => {
|
||||
const labelMatch = labelsMatch.get(label);
|
||||
// The condition is just to satisfy TS. The map should have all the labels due to the previous map initialization
|
||||
if (labelMatch) {
|
||||
labelMatch.match = true;
|
||||
labelMatch.matchers.push(matcher);
|
||||
}
|
||||
});
|
||||
|
||||
if (matchingLabels.length === 0) {
|
||||
return false;
|
||||
// record that matcher for the label
|
||||
if (matchingLabel) {
|
||||
labelsMatch.set(matchingLabel, {
|
||||
match: true,
|
||||
matcher,
|
||||
});
|
||||
}
|
||||
|
||||
details.set(matcher, matchingLabels);
|
||||
return matchingLabels.length > 0;
|
||||
});
|
||||
|
||||
return { matches, details, labelsMatch };
|
||||
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;
|
||||
matchDetails: Map<ObjectMatcher, Labels>;
|
||||
labelsMatch: Map<Label, LabelMatchResult>;
|
||||
labelsMatch: LabelsMatch;
|
||||
}
|
||||
|
||||
export interface RouteMatchResult<T extends Route> {
|
||||
route: T;
|
||||
details: Map<ObjectMatcher, Label[]>;
|
||||
labelsMatch: Map<Label, LabelMatchResult>;
|
||||
labelsMatch: LabelsMatch;
|
||||
}
|
||||
|
||||
// Match does a depth-first left-to-right search through the route tree
|
||||
// and returns the matching routing nodes.
|
||||
|
||||
// If the current node is not a match, return nothing
|
||||
// const normalizedMatchers = normalizeMatchers(root);
|
||||
// Normalization should have happened earlier in the code
|
||||
function findMatchingRoutes<T extends Route>(root: T, labels: Label[]): Array<RouteMatchResult<T>> {
|
||||
let matches: Array<RouteMatchResult<T>> = [];
|
||||
function findMatchingRoutes<T extends Route>(route: T, labels: Label[]): Array<RouteMatchResult<T>> {
|
||||
let childMatches: Array<RouteMatchResult<T>> = [];
|
||||
|
||||
// If the current node is not a match, return nothing
|
||||
const matchResult = matchLabels(root.object_matchers ?? [], labels);
|
||||
const matchResult = matchLabels(route.object_matchers ?? [], labels);
|
||||
if (!matchResult.matches) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If the current node matches, recurse through child nodes
|
||||
if (root.routes) {
|
||||
for (let index = 0; index < root.routes.length; index++) {
|
||||
let child = root.routes[index];
|
||||
if (route.routes) {
|
||||
for (const child of route.routes) {
|
||||
let matchingChildren = findMatchingRoutes(child, labels);
|
||||
// TODO how do I solve this typescript thingy? It looks correct to me /shrug
|
||||
// @ts-ignore
|
||||
matches = matches.concat(matchingChildren);
|
||||
childMatches = childMatches.concat(matchingChildren);
|
||||
// we have matching children and we don't want to continue, so break here
|
||||
if (matchingChildren.length && !child.continue) {
|
||||
break;
|
||||
@ -124,11 +97,11 @@ function findMatchingRoutes<T extends Route>(root: T, labels: Label[]): Array<Ro
|
||||
}
|
||||
|
||||
// If no child nodes were matches, the current node itself is a match.
|
||||
if (matches.length === 0) {
|
||||
matches.push({ route: root, details: matchResult.details, labelsMatch: matchResult.labelsMatch });
|
||||
if (childMatches.length === 0) {
|
||||
childMatches.push({ route, labelsMatch: matchResult.labelsMatch });
|
||||
}
|
||||
|
||||
return matches;
|
||||
return childMatches;
|
||||
}
|
||||
|
||||
// This is a performance improvement to normalize matchers only once and use the normalized version later on
|
||||
@ -249,4 +222,48 @@ export function computeInheritedTree<T extends Route>(parent: T): T {
|
||||
};
|
||||
}
|
||||
|
||||
export { findMatchingAlertGroups, findMatchingRoutes, getInheritedProperties };
|
||||
type OperatorPredicate = (labelValue: string, matcherValue: string) => boolean;
|
||||
const OperatorFunctions: Record<MatcherOperator, OperatorPredicate> = {
|
||||
[MatcherOperator.equal]: (lv, mv) => lv === mv,
|
||||
[MatcherOperator.notEqual]: (lv, mv) => lv !== mv,
|
||||
[MatcherOperator.regex]: (lv, mv) => new RegExp(mv).test(lv),
|
||||
[MatcherOperator.notRegex]: (lv, mv) => !new RegExp(mv).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 {
|
||||
let [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 { findMatchingAlertGroups, findMatchingRoutes, getInheritedProperties, isLabelMatchInSet };
|
||||
|
Loading…
Reference in New Issue
Block a user