Apply matchers encoding and decoding on the RTKQ layer

This commit is contained in:
Konrad Lalik 2024-02-02 14:46:40 +01:00
parent 820f4717bf
commit 4d963c43b5
No known key found for this signature in database
8 changed files with 98 additions and 78 deletions

View File

@ -1,3 +1,4 @@
import { produce } from 'immer';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { dispatch } from 'app/store/store'; import { dispatch } from 'app/store/store';
@ -15,7 +16,7 @@ import {
} from '../../../../plugins/datasource/alertmanager/types'; } from '../../../../plugins/datasource/alertmanager/types';
import { NotifierDTO } from '../../../../types'; import { NotifierDTO } from '../../../../types';
import { withPerformanceLogging } from '../Analytics'; import { withPerformanceLogging } from '../Analytics';
import { matcherToOperator } from '../utils/alertmanager'; import { matcherToOperator, quoteAmConfigMatchers, unquoteRouteMatchers } from '../utils/alertmanager';
import { import {
getDatasourceAPIUid, getDatasourceAPIUid,
GRAFANA_RULES_SOURCE_NAME, GRAFANA_RULES_SOURCE_NAME,
@ -196,7 +197,11 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
})); }));
} }
return result; return produce(result, (draft) => {
if (draft.alertmanager_config.route) {
unquoteRouteMatchers(draft.alertmanager_config.route);
}
});
}) })
.then((result) => result ?? defaultConfig) .then((result) => result ?? defaultConfig)
.then((result) => ({ data: result })) .then((result) => ({ data: result }))
@ -224,12 +229,14 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
void, void,
{ selectedAlertmanager: string; config: AlertManagerCortexConfig } { selectedAlertmanager: string; config: AlertManagerCortexConfig }
>({ >({
query: ({ selectedAlertmanager, config, ...rest }) => ({ query: ({ selectedAlertmanager, config, ...rest }) => {
url: `/api/alertmanager/${getDatasourceAPIUid(selectedAlertmanager)}/config/api/v1/alerts`, return {
method: 'POST', url: `/api/alertmanager/${getDatasourceAPIUid(selectedAlertmanager)}/config/api/v1/alerts`,
data: config, method: 'POST',
...rest, data: quoteAmConfigMatchers(config),
}), ...rest,
};
},
invalidatesTags: ['AlertmanagerConfiguration'], invalidatesTags: ['AlertmanagerConfiguration'],
}), }),

View File

@ -10,13 +10,15 @@ import { useDispatch } from 'app/types';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { expireSilenceAction } from '../../state/actions'; import { expireSilenceAction } from '../../state/actions';
import { parseMatchers } from '../../utils/alertmanager'; import { parseMatchers } from '../../utils/alertmanager';
import { matcherToObjectMatcher } from '../../utils/matchers';
import { getSilenceFiltersFromUrlParams, makeAMLink } from '../../utils/misc'; import { getSilenceFiltersFromUrlParams, makeAMLink } from '../../utils/misc';
import { Authorize } from '../Authorize'; import { Authorize } from '../Authorize';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { Matchers } from '../notification-policies/Matchers';
import { ActionButton } from '../rules/ActionButton'; import { ActionButton } from '../rules/ActionButton';
import { ActionIcon } from '../rules/ActionIcon'; import { ActionIcon } from '../rules/ActionIcon';
import { Matchers } from './Matchers'; // import { Matchers } from './Matchers';
import { NoSilencesSplash } from './NoSilencesCTA'; import { NoSilencesSplash } from './NoSilencesCTA';
import { SilenceDetails } from './SilenceDetails'; import { SilenceDetails } from './SilenceDetails';
import { SilenceStateTag } from './SilenceStateTag'; import { SilenceStateTag } from './SilenceStateTag';
@ -219,8 +221,9 @@ function useColumns(alertManagerSourceName: string) {
{ {
id: 'matchers', id: 'matchers',
label: 'Matching labels', label: 'Matching labels',
renderCell: function renderMatchers({ data: { matchers } }) { renderCell: function renderMatchers({ data: { matchers = [] } }) {
return <Matchers matchers={matchers || []} />; const objectMatchers = matchers.map(matcherToObjectMatcher);
return <Matchers matchers={objectMatchers} />;
}, },
size: 10, size: 10,
}, },

View File

@ -66,7 +66,11 @@ import {
setRulerRuleGroup, setRulerRuleGroup,
} from '../api/ruler'; } from '../api/ruler';
import { RuleFormType, RuleFormValues } from '../types/rule-form'; import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager'; import {
addDefaultsToAlertmanagerConfig,
quoteAmConfigMatchers,
removeMuteTimingFromRoute,
} from '../utils/alertmanager';
import { import {
getAllRulesSourceNames, getAllRulesSourceNames,
getRulesDataSource, getRulesDataSource,
@ -530,7 +534,11 @@ export const updateAlertManagerConfigAction = createAsyncThunk<void, UpdateAlert
'A newer Alertmanager configuration is available. Please reload the page and try again to not overwrite recent changes.' 'A newer Alertmanager configuration is available. Please reload the page and try again to not overwrite recent changes.'
); );
} }
await updateAlertManagerConfig(alertManagerSourceName, addDefaultsToAlertmanagerConfig(newConfig));
await updateAlertManagerConfig(
alertManagerSourceName,
addDefaultsToAlertmanagerConfig(quoteAmConfigMatchers(newConfig))
);
thunkAPI.dispatch(alertmanagerApi.util.invalidateTags(['AlertmanagerConfiguration'])); thunkAPI.dispatch(alertmanagerApi.util.invalidateTags(['AlertmanagerConfiguration']));
if (redirectPath) { if (redirectPath) {
const options = new URLSearchParams(redirectSearch ?? ''); const options = new URLSearchParams(redirectSearch ?? '');

View File

@ -1,3 +1,4 @@
import { produce } from 'immer';
import { isEqual, uniqWith } from 'lodash'; import { isEqual, uniqWith } from 'lodash';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
@ -16,6 +17,8 @@ import { MatcherFieldValue } from '../types/silence-form';
import { getAllDataSources } from './config'; import { getAllDataSources } from './config';
import { DataSourceType } from './datasource'; import { DataSourceType } from './datasource';
import { parseMatcher } from './matchers';
import { isQuoted, quoteWithEscape, unquoteWithUnescape } from './misc';
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig { export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
// add default receiver if it does not exist // add default receiver if it does not exist
@ -138,6 +141,39 @@ export function parseMatchers(matcherQueryString: string): Matcher[] {
return matchers; return matchers;
} }
export function unquoteRouteMatchers(route: Route): void {
if (route.matchers) {
route.matchers = route.matchers.map((stringMatcher) => {
const [name, operator, value] = matcherToObjectMatcher(parseMatcher(stringMatcher));
if (isQuoted(value)) {
return `${name}${operator}${unquoteWithUnescape(value)}`;
}
return stringMatcher;
});
}
route.routes?.forEach(unquoteRouteMatchers);
}
export function quoteRouteMatchers(route: Route): void {
if (route.matchers) {
route.matchers = route.matchers.map((stringMatcher) => {
const matcher = parseMatcher(stringMatcher);
const { name, value, operator } = matcherToMatcherField(matcher);
return `${name}${operator}${quoteWithEscape(value)}`;
});
}
route.routes?.forEach(quoteRouteMatchers);
}
export function quoteAmConfigMatchers(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
return produce(config, (draft) => {
if (draft.alertmanager_config.route) {
// Grafana AM doesn't use the matchers field so shouldn't be affected
quoteRouteMatchers(draft.alertmanager_config.route);
}
});
}
function getValidRegexString(regex: string): string { function getValidRegexString(regex: string): string {
// Regexes provided by users might be invalid, so we need to catch the error // Regexes provided by users might be invalid, so we need to catch the error
try { try {

View File

@ -53,30 +53,6 @@ describe('formAmRouteToAmRoute', () => {
expect(amRoute.group_by).toStrictEqual(['SHOULD BE SET']); expect(amRoute.group_by).toStrictEqual(['SHOULD BE SET']);
}); });
}); });
it('should quote and escape matcher values', () => {
// Arrange
const route: FormAmRoute = buildFormAmRoute({
id: '1',
object_matchers: [
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar' },
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar"baz' },
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar\\baz' },
{ name: 'foo', operator: MatcherOperator.equal, value: '\\bar\\baz"\\' },
],
});
// Act
const amRoute = formAmRouteToAmRoute('mimir-am', route, { id: 'root' });
// Assert
expect(amRoute.matchers).toStrictEqual([
'foo="bar"',
'foo="bar\\"baz"',
'foo="bar\\\\baz"',
'foo="\\\\bar\\\\baz\\"\\\\"',
]);
});
}); });
describe('amRouteToFormAmRoute', () => { describe('amRouteToFormAmRoute', () => {
@ -125,23 +101,4 @@ describe('amRouteToFormAmRoute', () => {
expect(formRoute.overrideGrouping).toBe(true); expect(formRoute.overrideGrouping).toBe(true);
}); });
}); });
it('should unquote and unescape matchers values', () => {
// Arrange
const amRoute = buildAmRoute({
matchers: ['foo=bar', 'foo="bar"', 'foo="bar"baz"', 'foo="bar\\\\baz"', 'foo="\\\\bar\\\\baz"\\\\"'],
});
// Act
const formRoute = amRouteToFormAmRoute(amRoute);
// Assert
expect(formRoute.object_matchers).toStrictEqual([
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar' },
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar' },
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar"baz' },
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar\\baz' },
{ name: 'foo', operator: MatcherOperator.equal, value: '\\bar\\baz"\\' },
]);
});
}); });

View File

@ -9,7 +9,6 @@ import { MatcherFieldValue } from '../types/silence-form';
import { matcherToMatcherField } from './alertmanager'; import { matcherToMatcherField } from './alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
import { normalizeMatchers, parseMatcher } from './matchers'; import { normalizeMatchers, parseMatcher } from './matchers';
import { quoteWithEscape, unquoteWithUnescape } from './misc';
import { findExistingRoute } from './routeTree'; import { findExistingRoute } from './routeTree';
import { isValidPrometheusDuration, safeParseDurationstr } from './time'; import { isValidPrometheusDuration, safeParseDurationstr } from './time';
@ -101,7 +100,7 @@ export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): Fo
.map(({ name, operator, value }) => ({ .map(({ name, operator, value }) => ({
name, name,
operator, operator,
value: unquoteWithUnescape(value), value,
})) ?? []; })) ?? [];
return { return {
@ -185,9 +184,7 @@ export const formAmRouteToAmRoute = (
// Grafana maintains a fork of AM to support all utf-8 characters in the "object_matchers" property values but this // Grafana maintains a fork of AM to support all utf-8 characters in the "object_matchers" property values but this
// does not exist in upstream AlertManager // does not exist in upstream AlertManager
if (alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME) { if (alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME) {
amRoute.matchers = formAmRoute.object_matchers?.map( amRoute.matchers = formAmRoute.object_matchers?.map(({ name, operator, value }) => `${name}${operator}${value}`);
({ name, operator, value }) => `${name}${operator}${quoteWithEscape(value)}`
);
amRoute.object_matchers = undefined; amRoute.object_matchers = undefined;
} else { } else {
amRoute.object_matchers = normalizeMatchers(amRoute); amRoute.object_matchers = normalizeMatchers(amRoute);

View File

@ -60,6 +60,27 @@ export const getMatcherQueryParams = (labels: Labels) => {
return matcherUrlParams; return matcherUrlParams;
}; };
export const matcherToObjectMatcher = (matcher: Matcher): ObjectMatcher => {
const { name, value, isRegex, isEqual } = matcher;
let operator = MatcherOperator.equal;
if (isEqual && isRegex) {
operator = MatcherOperator.regex;
}
if (!isEqual && isRegex) {
operator = MatcherOperator.notRegex;
}
if (isEqual && !isRegex) {
operator = MatcherOperator.equal;
}
if (!isEqual && !isRegex) {
operator = MatcherOperator.notEqual;
}
return [name, operator, value];
};
/** /**
* We need to deal with multiple (deprecated) properties such as "match" and "match_re" * 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. * this function will normalize all of the different ways to define matchers in to a single one.
@ -68,24 +89,11 @@ export const normalizeMatchers = (route: Route): ObjectMatcher[] => {
const matchers: ObjectMatcher[] = []; const matchers: ObjectMatcher[] = [];
if (route.matchers) { if (route.matchers) {
route.matchers.forEach((matcher) => { route.matchers.forEach((stringMatcher) => {
const { name, value, isEqual, isRegex } = parseMatcher(matcher); const matcher = parseMatcher(stringMatcher);
let operator = MatcherOperator.equal; const objectMatcher = matcherToObjectMatcher(matcher);
if (isEqual && isRegex) { matchers.push(objectMatcher);
operator = MatcherOperator.regex;
}
if (!isEqual && isRegex) {
operator = MatcherOperator.notRegex;
}
if (isEqual && !isRegex) {
operator = MatcherOperator.equal;
}
if (!isEqual && !isRegex) {
operator = MatcherOperator.notEqual;
}
matchers.push([name, operator, value]);
}); });
} }

View File

@ -129,6 +129,10 @@ export function unquoteWithUnescape(input: string) {
.replace(/\\"/g, '"'); .replace(/\\"/g, '"');
} }
export function isQuoted(input: string) {
return input.startsWith('"') && input.endsWith('"');
}
export function makeRuleBasedSilenceLink(alertManagerSourceName: string, rule: CombinedRule) { export function makeRuleBasedSilenceLink(alertManagerSourceName: string, rule: CombinedRule) {
// we wrap the name of the alert with quotes since it might contain starting and trailing spaces // we wrap the name of the alert with quotes since it might contain starting and trailing spaces
const labels: Labels = { const labels: Labels = {