Alerting: Add support for UTF-8 characters in notification policies and silences (#81455)

* Add label matcher validation to support UTF-8 characters

* Add double quotes wrapping and escaping on displating matcher form inputs

* Apply matchers encoding and decoding on the RTKQ layer

* Fix unescaping order

* Revert "Apply matchers encoding and decoding on the RTKQ layer"

This reverts commit 4d963c43b5.

* Add matchers formatter

* Fix code organization to prevent breaking worker

* Add matcher formatter to Policy and Modal components

* Unquote matchers when finding matching policy instances

* Add tests for quoting and unquoting

* Rename cloud matcher formatter

* Revert unintended change

* Allow empty matcher values

* fix test
pull/82329/head
Konrad Lalik 1 year ago committed by GitHub
parent 790e1feb93
commit fbdd27c237
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      public/app/features/alerting/unified/NotificationPolicies.tsx
  2. 2
      public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx
  3. 14
      public/app/features/alerting/unified/components/notification-policies/Matchers.tsx
  4. 26
      public/app/features/alerting/unified/components/notification-policies/Modals.tsx
  5. 21
      public/app/features/alerting/unified/components/notification-policies/Policy.tsx
  6. 10
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationPolicyMatchers.tsx
  7. 6
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx
  8. 24
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx
  9. 5
      public/app/features/alerting/unified/components/rule-editor/notificaton-preview/useAlertmanagerNotificationRoutingPreview.ts
  10. 25
      public/app/features/alerting/unified/routeGroupsMatcher.ts
  11. 60
      public/app/features/alerting/unified/useRouteGroupsMatcher.ts
  12. 11
      public/app/features/alerting/unified/utils/alertmanager.ts
  13. 74
      public/app/features/alerting/unified/utils/amroutes.test.ts
  14. 19
      public/app/features/alerting/unified/utils/amroutes.ts
  15. 42
      public/app/features/alerting/unified/utils/matchers.test.ts
  16. 37
      public/app/features/alerting/unified/utils/matchers.ts
  17. 16
      public/app/features/alerting/unified/utils/notification-policies.ts

@ -54,8 +54,8 @@ const AmRoutes = () => {
const [contactPointFilter, setContactPointFilter] = useState<string | undefined>();
const [labelMatchersFilter, setLabelMatchersFilter] = useState<ObjectMatcher[]>([]);
const { selectedAlertmanager, hasConfigurationAPI, isGrafanaAlertmanager } = useAlertmanager();
const { getRouteGroupsMap } = useRouteGroupsMatcher();
const { selectedAlertmanager, hasConfigurationAPI } = useAlertmanager();
const contactPointsState = useGetContactPointsState(selectedAlertmanager ?? '');
@ -93,9 +93,9 @@ const AmRoutes = () => {
useEffect(() => {
if (rootRoute && alertGroups) {
triggerGetRouteGroupsMap(rootRoute, alertGroups);
triggerGetRouteGroupsMap(rootRoute, alertGroups, { unquoteMatchers: !isGrafanaAlertmanager });
}
}, [rootRoute, alertGroups, triggerGetRouteGroupsMap]);
}, [rootRoute, alertGroups, triggerGetRouteGroupsMap, isGrafanaAlertmanager]);
// these are computed from the contactPoint and labels matchers filter
const routesMatchingFilters = useMemo(() => {

@ -134,7 +134,7 @@ export const AmRoutesExpandedForm = ({
error={errors.object_matchers?.[index]?.value?.message}
>
<Input
{...register(`object_matchers.${index}.value`, { required: 'Field is required' })}
{...register(`object_matchers.${index}.value`)}
defaultValue={field.value}
placeholder="value"
/>

@ -6,12 +6,13 @@ import { GrafanaTheme2 } from '@grafana/data';
import { getTagColorsFromName, useStyles2, Stack } from '@grafana/ui';
import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types';
import { MatcherFormatter, matcherFormatter } from '../../utils/matchers';
import { HoverCard } from '../HoverCard';
type MatchersProps = { matchers: ObjectMatcher[] };
type MatchersProps = { matchers: ObjectMatcher[]; formatter?: MatcherFormatter };
// renders the first N number of matchers
const Matchers: FC<MatchersProps> = ({ matchers }) => {
const Matchers: FC<MatchersProps> = ({ matchers, formatter = 'default' }) => {
const styles = useStyles2(getStyles);
const NUM_MATCHERS = 5;
@ -24,7 +25,7 @@ const Matchers: FC<MatchersProps> = ({ matchers }) => {
<span data-testid="label-matchers">
<Stack direction="row" gap={1} alignItems="center" wrap={'wrap'}>
{firstFew.map((matcher) => (
<MatcherBadge key={uniqueId()} matcher={matcher} />
<MatcherBadge key={uniqueId()} matcher={matcher} formatter={formatter} />
))}
{/* TODO hover state to show all matchers we're not showing */}
{hasMoreMatchers && (
@ -51,15 +52,16 @@ const Matchers: FC<MatchersProps> = ({ matchers }) => {
interface MatcherBadgeProps {
matcher: ObjectMatcher;
formatter?: MatcherFormatter;
}
const MatcherBadge: FC<MatcherBadgeProps> = ({ matcher: [label, operator, value] }) => {
const MatcherBadge: FC<MatcherBadgeProps> = ({ matcher, formatter = 'default' }) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.matcher(label).wrapper}>
<div className={styles.matcher(matcher[0]).wrapper}>
<Stack direction="row" gap={0} alignItems="baseline">
{label} {operator} {value}
{matcherFormatter[formatter](matcher)}
</Stack>
</div>
);

@ -11,6 +11,7 @@ import {
} from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../../types/amroutes';
import { MatcherFormatter } from '../../utils/matchers';
import { AlertGroup } from '../alert-groups/AlertGroup';
import { useGetAmRouteReceiverWithGrafanaAppTypes } from '../receivers/grafanaAppReceivers/grafanaApp';
@ -210,6 +211,7 @@ const useAlertGroupsModal = (): [
const [showModal, setShowModal] = useState(false);
const [alertGroups, setAlertGroups] = useState<AlertmanagerGroup[]>([]);
const [matchers, setMatchers] = useState<ObjectMatcher[]>([]);
const [formatter, setFormatter] = useState<MatcherFormatter>('default');
const handleDismiss = useCallback(() => {
setShowModal(false);
@ -217,13 +219,19 @@ const useAlertGroupsModal = (): [
setMatchers([]);
}, []);
const handleShow = useCallback((alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => {
setAlertGroups(alertGroups);
if (matchers) {
setMatchers(matchers);
}
setShowModal(true);
}, []);
const handleShow = useCallback(
(alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[], formatter?: MatcherFormatter) => {
setAlertGroups(alertGroups);
if (matchers) {
setMatchers(matchers);
}
if (formatter) {
setFormatter(formatter);
}
setShowModal(true);
},
[]
);
const instancesByState = useMemo(() => {
const instances = alertGroups.flatMap((group) => group.alerts);
@ -242,7 +250,7 @@ const useAlertGroupsModal = (): [
<Stack direction="row" alignItems="center" gap={0.5}>
<Icon name="x" /> Matchers
</Stack>
<Matchers matchers={matchers} />
<Matchers matchers={matchers} formatter={formatter} />
</Stack>
}
>
@ -265,7 +273,7 @@ const useAlertGroupsModal = (): [
</Modal.ButtonRow>
</Modal>
),
[alertGroups, handleDismiss, instancesByState, matchers, showModal]
[alertGroups, handleDismiss, instancesByState, matchers, formatter, showModal]
);
return [modalElement, handleShow, handleDismiss];

@ -32,7 +32,8 @@ import { ReceiversState } from 'app/types';
import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { INTEGRATION_ICONS } from '../../types/contact-points';
import { normalizeMatchers } from '../../utils/matchers';
import { getAmMatcherFormatter } from '../../utils/alertmanager';
import { MatcherFormatter, normalizeMatchers } from '../../utils/matchers';
import { createContactPointLink, createMuteTimingLink } from '../../utils/misc';
import { InheritableProperties, getInheritedProperties } from '../../utils/notification-policies';
import { Authorize } from '../Authorize';
@ -55,7 +56,6 @@ interface PolicyComponentProps {
provisioned?: boolean;
inheritedProperties?: Partial<InheritableProperties>;
routesMatchingFilters?: RouteWithID[];
// routeAlertGroupsMap?: Map<string, AlertmanagerGroup[]>;
matchingInstancesPreview?: { groupsMap?: Map<string, AlertmanagerGroup[]>; enabled: boolean };
@ -65,7 +65,11 @@ interface PolicyComponentProps {
onEditPolicy: (route: RouteWithID, isDefault?: boolean, isAutogenerated?: boolean) => void;
onAddPolicy: (route: RouteWithID) => void;
onDeletePolicy: (route: RouteWithID) => void;
onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void;
onShowAlertInstances: (
alertGroups: AlertmanagerGroup[],
matchers?: ObjectMatcher[],
formatter?: MatcherFormatter
) => void;
isAutoGenerated?: boolean;
}
@ -194,7 +198,7 @@ const Policy = (props: PolicyComponentProps) => {
<DefaultPolicyIndicator />
)
) : hasMatchers ? (
<Matchers matchers={matchers ?? []} />
<Matchers matchers={matchers ?? []} formatter={getAmMatcherFormatter(alertManagerSourceName)} />
) : (
<span className={styles.metadata}>No matchers</span>
)}
@ -325,7 +329,11 @@ interface MetadataRowProps {
matchingAlertGroups?: AlertmanagerGroup[];
matchers?: ObjectMatcher[];
isDefaultPolicy: boolean;
onShowAlertInstances: (alertGroups: AlertmanagerGroup[], matchers?: ObjectMatcher[]) => void;
onShowAlertInstances: (
alertGroups: AlertmanagerGroup[],
matchers?: ObjectMatcher[],
formatter?: MatcherFormatter
) => void;
}
function MetadataRow({
@ -361,7 +369,8 @@ function MetadataRow({
<MetaText
icon="layers-alt"
onClick={() => {
matchingAlertGroups && onShowAlertInstances(matchingAlertGroups, matchers);
matchingAlertGroups &&
onShowAlertInstances(matchingAlertGroups, matchers, getAmMatcherFormatter(alertManagerSourceName));
}}
data-testid="matching-instances"
>

@ -4,18 +4,24 @@ import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { MatcherFormatter } from '../../../utils/matchers';
import { Matchers } from '../../notification-policies/Matchers';
import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route';
export function NotificationPolicyMatchers({ route }: { route: RouteWithPath }) {
interface Props {
route: RouteWithPath;
matcherFormatter: MatcherFormatter;
}
export function NotificationPolicyMatchers({ route, matcherFormatter }: Props) {
const styles = useStyles2(getStyles);
if (isDefaultPolicy(route)) {
return <div className={styles.defaultPolicy}>Default policy</div>;
} else if (hasEmptyMatchers(route)) {
return <div className={styles.textMuted}>No matchers</div>;
} else {
return <Matchers matchers={route.object_matchers ?? []} />;
return <Matchers matchers={route.object_matchers ?? []} formatter={matcherFormatter} />;
}
}

@ -9,6 +9,7 @@ import { Button, getTagColorIndexFromName, TagList, useStyles2 } from '@grafana/
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { getAmMatcherFormatter } from '../../../utils/alertmanager';
import { AlertInstanceMatch } from '../../../utils/notification-policies';
import { CollapseToggle } from '../../CollapseToggle';
import { MetaText } from '../../MetaText';
@ -58,7 +59,10 @@ function NotificationRouteHeader({
<div onClick={() => onExpandRouteClick(!expandRoute)} className={styles.expandable}>
<Stack gap={1} direction="row" alignItems="center">
Notification policy
<NotificationPolicyMatchers route={route} />
<NotificationPolicyMatchers
route={route}
matcherFormatter={getAmMatcherFormatter(alertManagerSourceName)}
/>
</Stack>
</div>
<Spacer />

@ -3,20 +3,26 @@ import { compact } from 'lodash';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Icon, Modal, useStyles2 } from '@grafana/ui';
import { Button, Icon, Modal, Stack, useStyles2 } from '@grafana/ui';
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { AlertmanagerAction } from '../../../hooks/useAbilities';
import { AlertmanagerProvider } from '../../../state/AlertmanagerContext';
import { GRAFANA_DATASOURCE_NAME } from '../../../utils/datasource';
import { getAmMatcherFormatter } from '../../../utils/alertmanager';
import { MatcherFormatter } from '../../../utils/matchers';
import { makeAMLink } from '../../../utils/misc';
import { Authorize } from '../../Authorize';
import { Matchers } from '../../notification-policies/Matchers';
import { hasEmptyMatchers, isDefaultPolicy, RouteWithPath } from './route';
function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map<string, RouteWithPath>; route: RouteWithPath }) {
interface Props {
routesByIdMap: Map<string, RouteWithPath>;
route: RouteWithPath;
matcherFormatter: MatcherFormatter;
}
function PolicyPath({ route, routesByIdMap, matcherFormatter }: Props) {
const styles = useStyles2(getStyles);
const routePathIds = route.path?.slice(1) ?? [];
const routePathObjects = [...compact(routePathIds.map((id) => routesByIdMap.get(id))), route];
@ -31,7 +37,7 @@ function PolicyPath({ route, routesByIdMap }: { routesByIdMap: Map<string, Route
{hasEmptyMatchers(pathRoute) ? (
<div className={styles.textMuted}>No matchers</div>
) : (
<Matchers matchers={pathRoute.object_matchers ?? []} />
<Matchers matchers={pathRoute.object_matchers ?? []} formatter={matcherFormatter} />
)}
</div>
</div>
@ -60,7 +66,7 @@ export function NotificationRouteDetailsModal({
const isDefault = isDefaultPolicy(route);
return (
<AlertmanagerProvider accessType="notification" alertmanagerSourceName={GRAFANA_DATASOURCE_NAME}>
<AlertmanagerProvider accessType="notification" alertmanagerSourceName={alertManagerSourceName}>
<Modal
className={styles.detailsModal}
isOpen={true}
@ -77,7 +83,11 @@ export function NotificationRouteDetailsModal({
<div className={styles.separator(1)} />
{!isDefault && (
<>
<PolicyPath route={route} routesByIdMap={routesByIdMap} />
<PolicyPath
route={route}
routesByIdMap={routesByIdMap}
matcherFormatter={getAmMatcherFormatter(alertManagerSourceName)}
/>
</>
)}
<div className={styles.separator(4)} />

@ -6,6 +6,7 @@ import { Labels } from '../../../../../../types/unified-alerting-dto';
import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig';
import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher';
import { addUniqueIdentifierToRoute } from '../../../utils/amroutes';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import { AlertInstanceMatch, computeInheritedTree, normalizeRoute } from '../../../utils/notification-policies';
import { getRoutesByIdMap, RouteWithPath } from './route';
@ -55,7 +56,9 @@ export const useAlertmanagerNotificationRoutingPreview = (
if (!rootRoute) {
return;
}
return await matchInstancesToRoute(rootRoute, potentialInstances);
return await matchInstancesToRoute(rootRoute, potentialInstances, {
unquoteMatchers: alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME,
});
}, [rootRoute, potentialInstances]);
return {

@ -6,11 +6,20 @@ import {
findMatchingAlertGroups,
findMatchingRoutes,
normalizeRoute,
unquoteRouteMatchers,
} from './utils/notification-policies';
export interface MatchOptions {
unquoteMatchers?: boolean;
}
export const routeGroupsMatcher = {
getRouteGroupsMap(rootRoute: RouteWithID, groups: AlertmanagerGroup[]): Map<string, AlertmanagerGroup[]> {
const normalizedRootRoute = normalizeRoute(rootRoute);
getRouteGroupsMap(
rootRoute: RouteWithID,
groups: AlertmanagerGroup[],
options?: MatchOptions
): Map<string, AlertmanagerGroup[]> {
const normalizedRootRoute = getNormalizedRoute(rootRoute, options);
function addRouteGroups(route: RouteWithID, acc: Map<string, AlertmanagerGroup[]>) {
const routeGroups = findMatchingAlertGroups(normalizedRootRoute, route, groups);
@ -25,10 +34,14 @@ export const routeGroupsMatcher = {
return routeGroupsMap;
},
matchInstancesToRoute(routeTree: RouteWithID, instancesToMatch: Labels[]): Map<string, AlertInstanceMatch[]> {
matchInstancesToRoute(
routeTree: RouteWithID,
instancesToMatch: Labels[],
options?: MatchOptions
): Map<string, AlertInstanceMatch[]> {
const result = new Map<string, AlertInstanceMatch[]>();
const normalizedRootRoute = normalizeRoute(routeTree);
const normalizedRootRoute = getNormalizedRoute(routeTree, options);
instancesToMatch.forEach((instance) => {
const matchingRoutes = findMatchingRoutes(normalizedRootRoute, Object.entries(instance));
@ -47,4 +60,8 @@ export const routeGroupsMatcher = {
},
};
function getNormalizedRoute(route: RouteWithID, options?: MatchOptions): RouteWithID {
return options?.unquoteMatchers ? unquoteRouteMatchers(normalizeRoute(route)) : normalizeRoute(route);
}
export type RouteGroupsMatcher = typeof routeGroupsMatcher;

@ -6,7 +6,7 @@ import { Labels } from '../../../types/unified-alerting-dto';
import { logError, logInfo } from './Analytics';
import { createWorker } from './createRouteGroupsMatcherWorker';
import type { RouteGroupsMatcher } from './routeGroupsMatcher';
import type { MatchOptions, RouteGroupsMatcher } from './routeGroupsMatcher';
let routeMatcher: comlink.Remote<RouteGroupsMatcher> | undefined;
@ -55,43 +55,49 @@ export function useRouteGroupsMatcher() {
return () => null;
}, []);
const getRouteGroupsMap = useCallback(async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[]) => {
validateWorker(routeMatcher);
const getRouteGroupsMap = useCallback(
async (rootRoute: RouteWithID, alertGroups: AlertmanagerGroup[], options?: MatchOptions) => {
validateWorker(routeMatcher);
const startTime = performance.now();
const startTime = performance.now();
const result = await routeMatcher.getRouteGroupsMap(rootRoute, alertGroups);
const result = await routeMatcher.getRouteGroupsMap(rootRoute, alertGroups, options);
const timeSpent = performance.now() - startTime;
const timeSpent = performance.now() - startTime;
logInfo(`Route Groups Matched in ${timeSpent} ms`, {
matchingTime: timeSpent.toString(),
alertGroupsCount: alertGroups.length.toString(),
// Counting all nested routes might be too time-consuming, so we only count the first level
topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0',
});
logInfo(`Route Groups Matched in ${timeSpent} ms`, {
matchingTime: timeSpent.toString(),
alertGroupsCount: alertGroups.length.toString(),
// Counting all nested routes might be too time-consuming, so we only count the first level
topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0',
});
return result;
}, []);
return result;
},
[]
);
const matchInstancesToRoute = useCallback(async (rootRoute: RouteWithID, instancesToMatch: Labels[]) => {
validateWorker(routeMatcher);
const matchInstancesToRoute = useCallback(
async (rootRoute: RouteWithID, instancesToMatch: Labels[], options?: MatchOptions) => {
validateWorker(routeMatcher);
const startTime = performance.now();
const startTime = performance.now();
const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch);
const result = await routeMatcher.matchInstancesToRoute(rootRoute, instancesToMatch, options);
const timeSpent = performance.now() - startTime;
const timeSpent = performance.now() - startTime;
logInfo(`Instances Matched in ${timeSpent} ms`, {
matchingTime: timeSpent.toString(),
instancesToMatchCount: instancesToMatch.length.toString(),
// Counting all nested routes might be too time-consuming, so we only count the first level
topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0',
});
logInfo(`Instances Matched in ${timeSpent} ms`, {
matchingTime: timeSpent.toString(),
instancesToMatchCount: instancesToMatch.length.toString(),
// Counting all nested routes might be too time-consuming, so we only count the first level
topLevelRoutesCount: rootRoute.routes?.length.toString() ?? '0',
});
return result;
}, []);
return result;
},
[]
);
return { getRouteGroupsMap, matchInstancesToRoute };
}

@ -15,7 +15,8 @@ import { Labels } from 'app/types/unified-alerting-dto';
import { MatcherFieldValue } from '../types/silence-form';
import { getAllDataSources } from './config';
import { DataSourceType } from './datasource';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource';
import { MatcherFormatter, unquoteWithUnescape } from './matchers';
export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig {
// add default receiver if it does not exist
@ -53,6 +54,10 @@ export function renameMuteTimings(newMuteTimingName: string, oldMuteTimingName:
};
}
export function unescapeObjectMatchers(matchers: ObjectMatcher[]): ObjectMatcher[] {
return matchers.map(([name, operator, value]) => [name, operator, unquoteWithUnescape(value)]);
}
export function matcherToOperator(matcher: Matcher): MatcherOperator {
if (matcher.isEqual) {
if (matcher.isRegex) {
@ -177,6 +182,10 @@ export function combineMatcherStrings(...matcherStrings: string[]): string {
return matchersToString(uniqueMatchers);
}
export function getAmMatcherFormatter(alertmanagerSourceName?: string): MatcherFormatter {
return alertmanagerSourceName === GRAFANA_RULES_SOURCE_NAME ? 'default' : 'unquote';
}
export function getAllAlertmanagerDataSources() {
return getAllDataSources().filter((ds) => ds.type === DataSourceType.Alertmanager);
}

@ -1,8 +1,9 @@
import { Route } from 'app/plugins/datasource/alertmanager/types';
import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../types/amroutes';
import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
const emptyAmRoute: Route = {
receiver: '',
@ -53,6 +54,58 @@ describe('formAmRouteToAmRoute', () => {
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\\"\\\\"',
]);
});
it('should allow matchers with empty values for cloud AM', () => {
// Arrange
const route: FormAmRoute = buildFormAmRoute({
id: '1',
object_matchers: [{ name: 'foo', operator: MatcherOperator.equal, value: '' }],
});
// Act
const amRoute = formAmRouteToAmRoute('mimir-am', route, { id: 'root' });
// Assert
expect(amRoute.matchers).toStrictEqual(['foo=""']);
});
it('should allow matchers with empty values for Grafana AM', () => {
// Arrange
const route: FormAmRoute = buildFormAmRoute({
id: '1',
object_matchers: [{ name: 'foo', operator: MatcherOperator.equal, value: '' }],
});
// Act
const amRoute = formAmRouteToAmRoute(GRAFANA_RULES_SOURCE_NAME, route, { id: 'root' });
// Assert
expect(amRoute.object_matchers).toStrictEqual([['foo', MatcherOperator.equal, '']]);
});
});
describe('amRouteToFormAmRoute', () => {
@ -101,4 +154,23 @@ describe('amRouteToFormAmRoute', () => {
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"\\' },
]);
});
});

@ -8,7 +8,7 @@ import { MatcherFieldValue } from '../types/silence-form';
import { matcherToMatcherField } from './alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
import { normalizeMatchers, parseMatcher } from './matchers';
import { normalizeMatchers, parseMatcher, quoteWithEscape, unquoteWithUnescape } from './matchers';
import { findExistingRoute } from './routeTree';
import { isValidPrometheusDuration, safeParseDurationstr } from './time';
@ -94,7 +94,14 @@ export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): Fo
const objectMatchers =
route.object_matchers?.map((matcher) => ({ name: matcher[0], operator: matcher[1], value: matcher[2] })) ?? [];
const matchers = route.matchers?.map((matcher) => matcherToMatcherField(parseMatcher(matcher))) ?? [];
const matchers =
route.matchers
?.map((matcher) => matcherToMatcherField(parseMatcher(matcher)))
.map(({ name, operator, value }) => ({
name,
operator,
value: unquoteWithUnescape(value),
})) ?? [];
return {
id,
@ -149,8 +156,10 @@ export const formAmRouteToAmRoute = (
const overrideRepeatInterval = overrideTimings && repeatIntervalValue;
const repeat_interval = overrideRepeatInterval ? repeatIntervalValue : INHERIT_FROM_PARENT;
// Empty matcher values are valid. Such matchers require specified label to not exists
const object_matchers: ObjectMatcher[] | undefined = formAmRoute.object_matchers
?.filter((route) => route.name && route.value && route.operator)
?.filter((route) => route.name && route.operator && route.value !== null && route.value !== undefined)
.map(({ name, operator, value }) => [name, operator, value]);
const routes = formAmRoute.routes?.map((subRoute) =>
@ -176,7 +185,9 @@ export const formAmRouteToAmRoute = (
// 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
if (alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME) {
amRoute.matchers = formAmRoute.object_matchers?.map(({ name, operator, value }) => `${name}${operator}${value}`);
amRoute.matchers = formAmRoute.object_matchers?.map(
({ name, operator, value }) => `${name}${operator}${quoteWithEscape(value)}`
);
amRoute.object_matchers = undefined;
} else {
amRoute.object_matchers = normalizeMatchers(amRoute);

@ -1,6 +1,12 @@
import { MatcherOperator, Route } from '../../../../plugins/datasource/alertmanager/types';
import { getMatcherQueryParams, normalizeMatchers, parseQueryParamMatchers } from './matchers';
import {
getMatcherQueryParams,
normalizeMatchers,
parseQueryParamMatchers,
quoteWithEscape,
unquoteWithUnescape,
} from './matchers';
describe('Unified Alerting matchers', () => {
describe('getMatcherQueryParams tests', () => {
@ -61,3 +67,37 @@ describe('Unified Alerting matchers', () => {
});
});
});
describe('quoteWithEscape', () => {
const samples: string[][] = [
['bar', '"bar"'],
['b"ar"', '"b\\"ar\\""'],
['b\\ar\\', '"b\\\\ar\\\\"'],
['wa{r}ni$ng!', '"wa{r}ni$ng!"'],
];
it.each(samples)('should escape and quote %s to %s', (raw, quoted) => {
const quotedMatcher = quoteWithEscape(raw);
expect(quotedMatcher).toBe(quoted);
});
});
describe('unquoteWithUnescape', () => {
const samples: string[][] = [
['bar', 'bar'],
['"bar"', 'bar'],
['"b\\"ar\\""', 'b"ar"'],
['"b\\\\ar\\\\"', 'b\\ar\\'],
['"wa{r}ni$ng!"', 'wa{r}ni$ng!'],
];
it.each(samples)('should unquote and unescape %s to %s', (quoted, raw) => {
const unquotedMatcher = unquoteWithUnescape(quoted);
expect(unquotedMatcher).toBe(raw);
});
it('should not unescape unquoted string', () => {
const unquoted = unquoteWithUnescape('un\\"quo\\\\ted');
expect(unquoted).toBe('un\\"quo\\\\ted');
});
});

@ -108,4 +108,41 @@ export const normalizeMatchers = (route: Route): ObjectMatcher[] => {
return matchers;
};
/**
* Quotes string and escapes double quote and backslash characters
*/
export function quoteWithEscape(input: string) {
const escaped = input.replace(/[\\"]/g, (c) => `\\${c}`);
return `"${escaped}"`;
}
/**
* 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 => {
// Unquoted value can be an empty string which we want to display as ""
const unquotedValue = unquoteWithUnescape(value) || '""';
return `${name} ${operator} ${unquotedValue}`;
},
} as const;
export type MatcherFormatter = keyof typeof matcherFormatter;
export type Label = [string, string];

@ -9,7 +9,7 @@ import {
} from 'app/plugins/datasource/alertmanager/types';
import { Labels } from 'app/types/unified-alerting-dto';
import { Label, normalizeMatchers } from './matchers';
import { Label, 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
@ -124,6 +124,20 @@ export function normalizeRoute(rootRoute: RouteWithID): RouteWithID {
return normalizedRootRoute;
}
export function unquoteRouteMatchers(route: RouteWithID): RouteWithID {
function unquoteRoute(route: RouteWithID) {
route.object_matchers = route.object_matchers?.map(([name, operator, value]) => {
return [name, operator, unquoteWithUnescape(value)];
});
route.routes?.forEach(unquoteRoute);
}
const unwrappedRootRoute = structuredClone(route);
unquoteRoute(unwrappedRootRoute);
return unwrappedRootRoute;
}
/**
* find all of the groups that have instances that match the route, thay way we can find all instances
* (and their grouping) for the given route

Loading…
Cancel
Save