From 650616a4046a01e5406de69b2f34ca6e0a2b351d Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Fri, 5 Jul 2024 17:17:45 +0200 Subject: [PATCH] Alerting: Support `utf8_strict_mode: false` in Mimir (#90092) --- .../alerting/unified/utils/amroutes.test.ts | 12 +++++----- .../alerting/unified/utils/amroutes.ts | 7 +++--- .../alerting/unified/utils/matchers.test.ts | 17 ++++++++++++++ .../alerting/unified/utils/matchers.ts | 22 +++++++++++++++++++ 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/public/app/features/alerting/unified/utils/amroutes.test.ts b/public/app/features/alerting/unified/utils/amroutes.test.ts index 343e4587c4a..40441e890cc 100644 --- a/public/app/features/alerting/unified/utils/amroutes.test.ts +++ b/public/app/features/alerting/unified/utils/amroutes.test.ts @@ -72,10 +72,10 @@ describe('formAmRouteToAmRoute', () => { // Assert expect(amRoute.matchers).toStrictEqual([ - '"foo"="bar"', - '"foo"="bar\\"baz"', - '"foo"="bar\\\\baz"', - '"foo"="\\\\bar\\\\baz\\"\\\\"', + 'foo="bar"', + 'foo="bar\\"baz"', + 'foo="bar\\\\baz"', + 'foo="\\\\bar\\\\baz\\"\\\\"', ]); }); @@ -97,7 +97,7 @@ describe('formAmRouteToAmRoute', () => { // Assert expect(amRoute.matchers).toStrictEqual([ - '"foo"="bar"', + 'foo="bar"', '"foo with spaces"="bar"', '"foo\\\\slash"="bar"', '"foo\\"quote"="bar"', @@ -116,7 +116,7 @@ describe('formAmRouteToAmRoute', () => { const amRoute = formAmRouteToAmRoute('mimir-am', route, { id: 'root' }); // Assert - expect(amRoute.matchers).toStrictEqual(['"foo"=""']); + expect(amRoute.matchers).toStrictEqual(['foo=""']); }); it('should allow matchers with empty values for Grafana AM', () => { diff --git a/public/app/features/alerting/unified/utils/amroutes.ts b/public/app/features/alerting/unified/utils/amroutes.ts index fd59aac2a84..f186a2e3aea 100644 --- a/public/app/features/alerting/unified/utils/amroutes.ts +++ b/public/app/features/alerting/unified/utils/amroutes.ts @@ -8,7 +8,7 @@ import { MatcherFieldValue } from '../types/silence-form'; import { matcherToMatcherField } from './alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; -import { normalizeMatchers, parseMatcherToArray, quoteWithEscape, unquoteWithUnescape } from './matchers'; +import { encodeMatcher, normalizeMatchers, parseMatcherToArray, unquoteWithUnescape } from './matchers'; import { findExistingRoute } from './routeTree'; import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time'; @@ -189,9 +189,8 @@ 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 }) => `${quoteWithEscape(name)}${operator}${quoteWithEscape(value)}` - ); + // to support UTF-8 characters we must wrap label keys and values with double quotes if they contain reserved characters. + amRoute.matchers = formAmRoute.object_matchers?.map(encodeMatcher); amRoute.object_matchers = undefined; } else { amRoute.object_matchers = normalizeMatchers(amRoute); diff --git a/public/app/features/alerting/unified/utils/matchers.test.ts b/public/app/features/alerting/unified/utils/matchers.test.ts index c10b03749b6..c6caad93097 100644 --- a/public/app/features/alerting/unified/utils/matchers.test.ts +++ b/public/app/features/alerting/unified/utils/matchers.test.ts @@ -1,6 +1,7 @@ import { MatcherOperator, Route } from '../../../../plugins/datasource/alertmanager/types'; import { + encodeMatcher, getMatcherQueryParams, isPromQLStyleMatcher, matcherToObjectMatcher, @@ -9,6 +10,7 @@ import { parsePromQLStyleMatcher, parseQueryParamMatchers, quoteWithEscape, + quoteWithEscapeIfRequired, unquoteWithUnescape, } from './matchers'; @@ -175,4 +177,19 @@ describe('parsePromQLStyleMatcher', () => { it('should throw when not using correct syntax', () => { expect(() => parsePromQLStyleMatcher('foo="bar"')).toThrow(); }); + + it('should only encode matchers if the label key contains reserved characters', () => { + expect(quoteWithEscapeIfRequired('foo')).toBe('foo'); + expect(quoteWithEscapeIfRequired('foo bar')).toBe('"foo bar"'); + expect(quoteWithEscapeIfRequired('foo{}bar')).toBe('"foo{}bar"'); + expect(quoteWithEscapeIfRequired('foo\\bar')).toBe('"foo\\\\bar"'); + }); + + it('should properly encode a matcher field', () => { + expect(encodeMatcher({ name: 'foo', operator: MatcherOperator.equal, value: 'baz' })).toBe('foo="baz"'); + expect(encodeMatcher({ name: 'foo bar', operator: MatcherOperator.equal, value: 'baz' })).toBe('"foo bar"="baz"'); + expect(encodeMatcher({ name: 'foo{}bar', operator: MatcherOperator.equal, value: 'baz qux' })).toBe( + '"foo{}bar"="baz qux"' + ); + }); }); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index a596a318da0..e5aea380b62 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -10,6 +10,7 @@ import { compact, uniqBy } from 'lodash'; 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'; @@ -144,6 +145,27 @@ export function quoteWithEscape(input: string) { 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 const encodeMatcher = ({ name, operator, value }: MatcherFieldValue) => { + const encodedLabelName = quoteWithEscapeIfRequired(name); + const encodedLabelValue = quoteWithEscape(value); + + return `${encodedLabelName}${operator}${encodedLabelValue}`; +}; + /** * Unquotes and unescapes a string **if it has been quoted** */