From 43be84076cc3898d31ff23a2c07b5217637946a3 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Fri, 3 Oct 2025 16:43:03 +0200 Subject: [PATCH] Alerting: Migrate `spec.title` and `spec.name` fieldSelectors (#111993) Migrate `spec.title` and `spec.name` fieldSelectors to use base64URL encoded `metadata.name` selectors since the `spec` properties aren't indexed and no longer searchable in the new app platform API. --- eslint-suppressions.json | 5 -- .../src/grafana/api/util.test.ts | 70 ++++++++++++++++++- .../grafana-alerting/src/grafana/api/util.ts | 36 ++++++++++ packages/grafana-alerting/src/index.ts | 3 + .../mute-timings/MuteTimingsTable.test.tsx | 6 +- .../mute-timings/useMuteTimings.tsx | 9 ++- .../contactPoint/ContactPointSelector.tsx | 13 +++- .../notificaton-preview/ContactPointGroup.tsx | 5 +- .../rule-viewer/ContactPointLink.tsx | 8 ++- .../server/handlers/k8s/timeIntervals.k8s.ts | 15 ++-- .../alerting/unified/mocks/server/utils.ts | 3 +- .../alerting/unified/utils/k8s/utils.ts | 4 +- 12 files changed, 151 insertions(+), 26 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 15dbecf1ed4..43845407d57 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1638,11 +1638,6 @@ "count": 8 } }, - "public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, "public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/ActiveTimingFields.tsx": { "no-restricted-syntax": { "count": 1 diff --git a/packages/grafana-alerting/src/grafana/api/util.test.ts b/packages/grafana-alerting/src/grafana/api/util.test.ts index 215cba04187..f8278bbdc72 100644 --- a/packages/grafana-alerting/src/grafana/api/util.test.ts +++ b/packages/grafana-alerting/src/grafana/api/util.test.ts @@ -1,6 +1,6 @@ import { config } from '@grafana/runtime'; -import { getAPIBaseURL, getAPINamespace, getAPIReducerPath } from './util'; +import { base64UrlEncode, getAPIBaseURL, getAPINamespace, getAPIReducerPath } from './util'; describe('API utilities', () => { const originalAppSubUrl = config.appSubUrl; @@ -64,4 +64,72 @@ describe('API utilities', () => { expect(result).toBe('notifications.alerting.grafana.app/v0alpha1'); }); }); + + describe('base64UrlEncode', () => { + it('should encode simple ASCII strings', () => { + expect(base64UrlEncode('hello')).toBe('aGVsbG8'); + }); + + it('should encode strings with special characters', () => { + expect(base64UrlEncode('hello world!')).toBe('aGVsbG8gd29ybGQh'); + }); + + it('should handle emoji characters correctly', () => { + // Single emoji + expect(base64UrlEncode('⛳')).toBe('4puz'); + // Multi-byte emoji + expect(base64UrlEncode('🧀')).toBe('8J-ngA'); + // Emoji with variant selector + expect(base64UrlEncode('❤️')).toBe('4p2k77iP'); + }); + + it('should handle mixed ASCII and Unicode characters', () => { + const input = 'hello⛳❤️🧀'; + const encoded = base64UrlEncode(input); + expect(encoded).toBe('aGVsbG_im7PinaTvuI_wn6eA'); + }); + + it('should convert to base64url format (no padding)', () => { + // Standard base64 would have padding with '=' + const result = base64UrlEncode('test'); + expect(result).not.toContain('='); + }); + + it('should replace + with - and / with _', () => { + // String that produces both + and / in standard base64 + const input = 'a??b'; // produces 'YT8/Yg==' in base64, which has / + const input2 = 'a?>b'; // produces 'YT8+Yg==' in base64, which has + + const encoded = base64UrlEncode(input); + const encoded2 = base64UrlEncode(input2); + expect(encoded).not.toContain('+'); + expect(encoded).not.toContain('/'); + expect(encoded2).not.toContain('+'); + expect(encoded2).not.toContain('/'); + expect(encoded).toContain('_'); // Should have _ instead of / + expect(encoded2).toContain('-'); // Should have - instead of + + }); + + it('should handle empty strings', () => { + expect(base64UrlEncode('')).toBe(''); + }); + + it('should handle contact point names with special characters', () => { + expect(base64UrlEncode('my-contact-point')).toBe('bXktY29udGFjdC1wb2ludA'); + expect(base64UrlEncode('Contact Point 🔔')).toBe('Q29udGFjdCBQb2ludCDwn5SU'); + }); + + it('should throw error for malformed UTF-16 strings with lone surrogates', () => { + // String with lone high surrogate + const malformedString = 'hello\uDE75'; + expect(() => base64UrlEncode(malformedString)).toThrow( + 'Cannot encode malformed UTF-16 string with lone surrogates' + ); + }); + + it('should handle well-formed strings with proper surrogate pairs', () => { + // Proper surrogate pair for emoji (U+1F9C0) + const wellFormedString = 'hello\uD83E\uDDC0'; + expect(() => base64UrlEncode(wellFormedString)).not.toThrow(); + }); + }); }); diff --git a/packages/grafana-alerting/src/grafana/api/util.ts b/packages/grafana-alerting/src/grafana/api/util.ts index a562e151366..cc7787716a4 100644 --- a/packages/grafana-alerting/src/grafana/api/util.ts +++ b/packages/grafana-alerting/src/grafana/api/util.ts @@ -13,3 +13,39 @@ export const getAPIBaseURL = (group: string, version: string) => { // By including the version in the reducer path we can prevent cache bugs when different versions of the API are used for the same entities export const getAPIReducerPath = (group: string, version: string) => `${group}/${version}` as const; + +/** + * Check if a string is well-formed UTF-16 (no lone surrogates). + * encodeURIComponent() throws an error for lone surrogates + */ +export const isWellFormed = (str: string): boolean => { + try { + encodeURIComponent(str); + return true; + } catch (error) { + return false; + } +}; + +/** + * Base64URL encode a string using native browser APIs. + * Handles Unicode characters correctly by using TextEncoder. + * Converts standard base64 to base64url by replacing + with -, / with _, and removing padding. + * @throws Error if the input string contains lone surrogates (malformed UTF-16) + */ +export const base64UrlEncode = (value: string): string => { + // Check if the string is well-formed UTF-16 + if (!isWellFormed(value)) { + throw new Error(`Cannot encode malformed UTF-16 string with lone surrogates: ${value}`); + } + + // Encode UTF-8 string to bytes + const bytes = new TextEncoder().encode(value); + + // Convert bytes to base64 + const binString = String.fromCodePoint(...bytes); + const base64 = btoa(binString); + + // Convert to base64url format + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +}; diff --git a/packages/grafana-alerting/src/index.ts b/packages/grafana-alerting/src/index.ts index 288d2c776fd..e7e8f4b22d2 100644 --- a/packages/grafana-alerting/src/index.ts +++ b/packages/grafana-alerting/src/index.ts @@ -11,5 +11,8 @@ export { AlertLabels } from './grafana/rules/components/labels/AlertLabels'; export { AlertLabel } from './grafana/rules/components/labels/AlertLabel'; // keep label utils internal to the app for now +// Utilities +export { base64UrlEncode } from './grafana/api/util'; + // This is a dummy export so typescript doesn't error importing an "empty module" export const index = {}; diff --git a/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.test.tsx b/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.test.tsx index d9d611d7a00..0bce93870f9 100644 --- a/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.test.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/MuteTimingsTable.test.tsx @@ -1,5 +1,6 @@ import { render, screen, userEvent, within } from 'test/test-utils'; +import { base64UrlEncode } from '@grafana/alerting'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { setMuteTimingsListError, @@ -10,7 +11,7 @@ import { captureRequests } from 'app/features/alerting/unified/mocks/server/even import { AccessControlAction } from 'app/types/accessControl'; import { grantUserPermissions } from '../../mocks'; -import { TIME_INTERVAL_UID_HAPPY_PATH } from '../../mocks/server/handlers/k8s/timeIntervals.k8s'; +import { TIME_INTERVAL_NAME_HAPPY_PATH } from '../../mocks/server/handlers/k8s/timeIntervals.k8s'; import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; @@ -113,8 +114,9 @@ describe('MuteTimingsTable', () => { await user.click(await screen.findByRole('button', { name: /delete/i })); const requests = await capture; + const encodedName = base64UrlEncode(TIME_INTERVAL_NAME_HAPPY_PATH); const deleteRequest = requests.find( - (r) => r.url.includes(`timeintervals/${TIME_INTERVAL_UID_HAPPY_PATH}`) && r.method === 'DELETE' + (r) => r.url.includes(`timeintervals/${encodedName}`) && r.method === 'DELETE' ); expect(deleteRequest).toBeDefined(); diff --git a/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx b/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx index aad08e47548..94e290a2087 100644 --- a/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx +++ b/public/app/features/alerting/unified/components/mute-timings/useMuteTimings.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react'; +import { base64UrlEncode } from '@grafana/alerting'; import { alertmanagerApi } from 'app/features/alerting/unified/api/alertmanagerApi'; import { timeIntervalsApi } from 'app/features/alerting/unified/api/timeIntervalsApi'; import { mergeTimeIntervals } from 'app/features/alerting/unified/components/mute-timings/util'; @@ -10,9 +11,9 @@ import { import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; import { PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; import { - encodeFieldSelector, isK8sEntityProvisioned, shouldUseK8sApi, + stringifyFieldSelector, } from 'app/features/alerting/unified/utils/k8s/utils'; import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types'; @@ -203,8 +204,10 @@ export const useGetMuteTiming = ({ alertmanager, name: nameToFind }: BaseAlertma useEffect(() => { if (useK8sApi) { const namespace = getAPINamespace(); - const entityName = encodeFieldSelector(nameToFind); - getGrafanaTimeInterval({ namespace, fieldSelector: `spec.name=${entityName}` }, true); + getGrafanaTimeInterval( + { namespace, fieldSelector: stringifyFieldSelector([['metadata.name', base64UrlEncode(nameToFind)]]) }, + true + ); } else { getAlertmanagerTimeInterval(alertmanager, true); } diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx index c512615ce78..a71930c8b88 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx @@ -3,10 +3,12 @@ import { isEmpty } from 'lodash'; import { useEffect } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; +import { base64UrlEncode } from '@grafana/alerting'; import { ContactPointSelector as GrafanaManagedContactPointSelector, alertingAPI } from '@grafana/alerting/unstable'; import { Trans, t } from '@grafana/i18n'; import { Field, FieldValidationMessage, Stack, TextLink } from '@grafana/ui'; import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; +import { stringifyFieldSelector } from 'app/features/alerting/unified/utils/k8s/utils'; import { createRelativeUrl } from 'app/features/alerting/unified/utils/url'; export interface ContactPointSelectorProps { @@ -21,9 +23,13 @@ export function ContactPointSelector({ alertManager }: ContactPointSelectorProps // check if the contact point still exists, we'll use listReceiver to check if the contact point exists because getReceiver doesn't work with // contact point titles but with UUIDs (which is not what we store on the alert rule definition) - const { currentData, status } = alertingAPI.endpoints.listReceiver.useQuery({ - fieldSelector: `spec.title=${contactPointInForm}`, - }); + const encodedContactPoint = contactPointInForm ? base64UrlEncode(contactPointInForm) : ''; + const { currentData, status } = alertingAPI.endpoints.listReceiver.useQuery( + { + fieldSelector: stringifyFieldSelector([['metadata.name', encodedContactPoint]]), + }, + { skip: !contactPointInForm } + ); const contactPointNotFound = contactPointInForm && status === QueryStatus.fulfilled && isEmpty(currentData?.items); @@ -37,6 +43,7 @@ export function ContactPointSelector({ alertManager }: ContactPointSelectorProps return ( diff --git a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/ContactPointGroup.tsx b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/ContactPointGroup.tsx index ba69db35766..9d4510a2ac1 100644 --- a/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/ContactPointGroup.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/notificaton-preview/ContactPointGroup.tsx @@ -3,6 +3,7 @@ import { PropsWithChildren, ReactNode } from 'react'; import Skeleton from 'react-loading-skeleton'; import { useToggle } from 'react-use'; +import { base64UrlEncode } from '@grafana/alerting'; import { alertingAPI, getContactPointDescription } from '@grafana/alerting/unstable'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; @@ -23,8 +24,10 @@ interface ContactPointGroupProps extends PropsWithChildren { export function GrafanaContactPointGroup({ name, matchedInstancesCount, children }: ContactPointGroupProps) { // find receiver by name – since this is what we store in the alert rule definition + const encodedName = base64UrlEncode(name); + const { data, isLoading } = alertingAPI.endpoints.listReceiver.useQuery({ - fieldSelector: stringifyFieldSelector([['spec.title', name]]), + fieldSelector: stringifyFieldSelector([['metadata.name', encodedName]]), }); // grab the first result from the fieldSelector result diff --git a/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.tsx b/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.tsx index b3c9196e664..741708860e0 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.tsx @@ -1,9 +1,11 @@ import { ComponentProps } from 'react'; import Skeleton from 'react-loading-skeleton'; +import { base64UrlEncode } from '@grafana/alerting'; import { alertingAPI } from '@grafana/alerting/unstable'; import { TextLink } from '@grafana/ui'; +import { stringifyFieldSelector } from '../../utils/k8s/utils'; import { makeEditContactPointLink } from '../../utils/misc'; interface ContactPointLinkProps extends Omit, 'href' | 'children'> { @@ -11,9 +13,11 @@ interface ContactPointLinkProps extends Omit, 'h } export const ContactPointLink = ({ name, ...props }: ContactPointLinkProps) => { - // find receiver by name – since this is what we store in the alert rule definition + const encodedName = base64UrlEncode(name); + + // find receiver by name using metadata.name field selector const { currentData, isLoading, isSuccess } = alertingAPI.endpoints.listReceiver.useQuery({ - fieldSelector: `spec.title=${name}`, + fieldSelector: stringifyFieldSelector([['metadata.name', encodedName]]), }); // grab the first result from the fieldSelector result diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts index 126423077d7..84503c2ce13 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s.ts @@ -1,9 +1,10 @@ import { HttpResponse, http } from 'msw'; +import { base64UrlEncode } from '@grafana/alerting'; import { filterBySelector } from 'app/features/alerting/unified/mocks/server/handlers/k8s/utils'; import { ALERTING_API_SERVER_BASE_URL, getK8sResponse } from 'app/features/alerting/unified/mocks/server/utils'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1TimeInterval } from 'app/features/alerting/unified/openapi/timeIntervalsApi.gen'; -import { PROVENANCE_ANNOTATION, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { K8sAnnotations, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; /** UID of a time interval that we expect to follow all happy paths within tests/mocks */ export const TIME_INTERVAL_UID_HAPPY_PATH = 'f4eae7a4895fa786'; @@ -20,9 +21,9 @@ const allTimeIntervals = getK8sResponse ); } - // Rudimentary filter support for `spec.name` + // Rudimentary filter support for `metadata.name` const url = new URL(request.url); const fieldSelector = url.searchParams.get('fieldSelector'); - if (fieldSelector && fieldSelector.includes('spec.name')) { + if (fieldSelector && fieldSelector.includes('metadata.name')) { const filteredItems = filterBySelector(allTimeIntervals.items, fieldSelector); return HttpResponse.json({ items: filteredItems }); diff --git a/public/app/features/alerting/unified/mocks/server/utils.ts b/public/app/features/alerting/unified/mocks/server/utils.ts index 5f01e038225..0ff0618a5f2 100644 --- a/public/app/features/alerting/unified/mocks/server/utils.ts +++ b/public/app/features/alerting/unified/mocks/server/utils.ts @@ -1,5 +1,6 @@ import { DefaultBodyType, HttpResponse, HttpResponseResolver, PathParams } from 'msw'; +import { base64UrlEncode } from '@grafana/alerting'; import { PromRuleGroupDTO, PromRulesResponse } from 'app/types/unified-alerting-dto'; /** Helper method to help generate a kubernetes-style response with a list of items */ @@ -20,7 +21,7 @@ export function paginatedHandlerFor( ): HttpResponseResolver { const orderedGroupsWithCursor = groups.map((group) => ({ ...group, - id: Buffer.from(`${group.file}-${group.name}`).toString('base64url'), + id: base64UrlEncode(`${group.file}-${group.name}`), })); return ({ request }) => { diff --git a/public/app/features/alerting/unified/utils/k8s/utils.ts b/public/app/features/alerting/unified/utils/k8s/utils.ts index e75818cde66..8aecb9dd46b 100644 --- a/public/app/features/alerting/unified/utils/k8s/utils.ts +++ b/public/app/features/alerting/unified/utils/k8s/utils.ts @@ -52,5 +52,7 @@ export const encodeFieldSelector = (value: string): string => { type FieldSelector = [string, string] | [string, string, '=' | '!=']; export const stringifyFieldSelector = (fieldSelectors: FieldSelector[]): string => { - return fieldSelectors.map(([key, value, operator = '=']) => `${key}${operator}${value}`).join(','); + return fieldSelectors + .map(([key, value, operator = '=']) => `${key}${operator}${encodeFieldSelector(value)}`) + .join(','); };