The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/features/alerting/unified/utils/matchers.ts

331 lines
11 KiB

/**
* Functions in this file are used by the routeGroupsMatcher.worker.ts file.
* This is a web worker that matches active alert instances to a policy in the notification policy tree.
*
* Please keep the references to other files here to a minimum, if we reference a file that uses GrafanaBootData from `window` the worker will fail to load.
*/
import { chain, compact } 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';
import { MatcherFieldValue } from '../types/silence-form';
import { isPrivateLabelKey } from './labels';
const matcherOperators = [
MatcherOperator.regex,
MatcherOperator.notRegex,
MatcherOperator.notEqual,
MatcherOperator.equal,
];
/**
* Parse a single matcher, examples:
* foo="bar" => { name: foo, value: bar, isRegex: false, isEqual: true }
* bar!~baz => { name: bar, value: baz, isRegex: true, isEqual: false }
*/
export function parseMatcher(matcher: string): Matcher {
if (matcher.startsWith('{') && matcher.endsWith('}')) {
throw new Error(
'this function does not support PromQL-style matcher syntax, call parsePromQLStyleMatcher() instead'
);
}
const operatorsFound = matcherOperators
.map((op): [MatcherOperator, number] => [op, matcher.indexOf(op)])
.filter(([_, idx]) => idx > -1)
.sort((a, b) => a[1] - b[1]);
if (!operatorsFound.length) {
throw new Error(`Invalid matcher: ${matcher}`);
}
const [operator, idx] = operatorsFound[0];
const name = matcher.slice(0, idx).trim();
const value = matcher.slice(idx + operator.length);
if (!name) {
throw new Error(`Invalid matcher: ${matcher}`);
}
return {
name,
value,
isRegex: operator === MatcherOperator.regex || operator === MatcherOperator.notRegex,
isEqual: operator === MatcherOperator.equal || operator === MatcherOperator.regex,
};
}
/**
* This function combines parseMatcher and parsePromQLStyleMatcher, always returning an array of Matcher[] regardless of input syntax
* 1. { foo=bar, bar=baz }
* 2. foo=bar
*/
export function parseMatcherToArray(matcher: string): Matcher[] {
return isPromQLStyleMatcher(matcher) ? parsePromQLStyleMatcher(matcher) : [parseMatcher(matcher)];
}
/**
* This function turns a PromQL-style matchers like { foo="bar", bar!=baz } in to an array of Matchers
*/
export function parsePromQLStyleMatcher(matcher: string): Matcher[] {
if (!isPromQLStyleMatcher(matcher)) {
throw new Error('not a PromQL style matcher');
}
return parsePromQLStyleMatcherLoose(matcher);
}
/**
* This function behaves the same as "parsePromQLStyleMatcher" but does not check if the matcher is formatted with { }
* In other words; it accepts both "{ foo=bar, bar=baz }" and "foo=bar,bar=baz"
* @throws
*/
export function parsePromQLStyleMatcherLoose(matcher: string): Matcher[] {
// split by `,` but not when it's used as a label value
const commaUnlessQuoted = /,(?=(?:[^"]*"[^"]*")*[^"]*$)/;
const parts = matcher.replace(/^\{/, '').replace(/\}$/, '').trim().split(commaUnlessQuoted);
return compact(parts)
.flatMap(parseMatcher)
.map((matcher) => ({
...matcher,
name: unquoteWithUnescape(matcher.name),
value: unquoteWithUnescape(matcher.value),
}));
}
/**
* This function behaves the same as "parsePromQLStyleMatcherLoose" but instead of throwing an error for incorrect syntax
* it returns an empty Array of matchers instead.
*/
export function parsePromQLStyleMatcherLooseSafe(matcher: string): Matcher[] {
try {
return parsePromQLStyleMatcherLoose(matcher);
} catch {
return [];
}
}
// Parses a list of entries like like "['foo=bar', 'baz=~bad*']" into SilenceMatcher[]
export function parseQueryParamMatchers(matcherPairs: string[]): Matcher[] {
return (
chain(matcherPairs)
.map((m) => m.trim()) // trim spaces
.compact() // remove empty strings
.flatMap(parsePromQLStyleMatcherLooseSafe)
// Due to migration, old alert rules might have a duplicated alertname label
// To handle that case want to filter out duplicates and make sure there are only unique labels
.uniqBy('name')
.value()
);
}
export const getMatcherQueryParams = (labels: Labels) => {
const validMatcherLabels = Object.entries(labels).filter(([labelKey]) => !isPrivateLabelKey(labelKey));
const matcherUrlParams = new URLSearchParams();
validMatcherLabels.forEach(([labelKey, labelValue]) =>
matcherUrlParams.append('matcher', `${labelKey}=${labelValue}`)
);
return matcherUrlParams;
};
/**
* We need to deal with multiple (deprecated) properties such as "match" and "match_re"
* this function will normalize all of the different ways to define matchers in to a single one.
*/
export const normalizeMatchers = (route: Route): ObjectMatcher[] => {
let routeMatchers: ObjectMatcher[] = [];
if (route.matchers) {
route.matchers.forEach((matcher) => {
const parsedMatchers = parseMatcherToArray(matcher).map(matcherToObjectMatcher);
routeMatchers = routeMatchers.concat(parsedMatchers);
});
}
if (route.object_matchers) {
routeMatchers.push(...route.object_matchers);
}
if (route.match_re) {
Object.entries(route.match_re).forEach(([label, value]) => {
routeMatchers.push([label, MatcherOperator.regex, value]);
});
}
if (route.match) {
Object.entries(route.match).forEach(([label, value]) => {
routeMatchers.push([label, MatcherOperator.equal, value]);
});
}
return routeMatchers;
};
/**
* Quotes string and escapes double quote and backslash characters
*/
export function quoteWithEscape(input: string) {
const escaped = input.replace(/[\\"]/g, (c) => `\\${c}`);
return `"${escaped}"`;
}
// The list of reserved characters that indicate we should be escaping the label key / value are
// { } ! = ~ , \ " ' ` and any whitespace (\s), encoded in the regular expression below
//
// See Alertmanager PR: https://github.com/prometheus/alertmanager/pull/3453
const RESERVED_CHARACTERS = /[\{\}\!\=\~\,\\\"\'\`\s]+/;
/**
* Quotes string only when reserved characters are used
*/
export function quoteWithEscapeIfRequired(input: string) {
const shouldQuote = RESERVED_CHARACTERS.test(input);
return shouldQuote ? quoteWithEscape(input) : input;
}
export function unquoteIfRequired(input: string) {
return quoteWithEscapeIfRequired(unquoteWithUnescape(input));
}
export const encodeMatcher = ({ name, operator, value }: MatcherFieldValue) => {
const encodedLabelName = quoteWithEscapeIfRequired(name);
// @TODO why not use quoteWithEscapeIfRequired?
const encodedLabelValue = quoteWithEscape(value);
return `${encodedLabelName}${operator}${encodedLabelValue}`;
};
/**
* Unquotes and unescapes a string **if it has been quoted**
*/
export function unquoteWithUnescape(input: string) {
if (!/^"(.*)"$/.test(input)) {
return input;
}
return input
.replace(/^"(.*)"$/, '$1')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
}
export const matcherFormatter = {
default: ([name, operator, value]: ObjectMatcher): string => {
// Value can be an empty string which we want to display as ""
const formattedValue = value || '';
return `${name} ${operator} ${formattedValue}`;
},
unquote: ([name, operator, value]: ObjectMatcher): string => {
const unquotedName = unquoteWithUnescape(name);
// Unquoted value can be an empty string which we want to display as ""
const unquotedValue = unquoteWithUnescape(value) || '""';
return `${unquotedName} ${operator} ${unquotedValue}`;
},
} as const;
export function isPromQLStyleMatcher(input: string): boolean {
return input.startsWith('{') && input.endsWith('}');
}
export function matcherToObjectMatcher(matcher: Matcher): ObjectMatcher {
const operator = matcherToOperator(matcher);
return [matcher.name, operator, matcher.value];
}
function matcherToOperator(matcher: Matcher): MatcherOperator {
if (matcher.isEqual) {
if (matcher.isRegex) {
return MatcherOperator.regex;
} else {
return MatcherOperator.equal;
}
} else if (matcher.isRegex) {
return MatcherOperator.notRegex;
} else {
return MatcherOperator.notEqual;
}
}
// 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];