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.
This commit is contained in:
Gilles De Mey 2025-10-03 16:43:03 +02:00 committed by GitHub
parent d0f79ee60d
commit 43be84076c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 151 additions and 26 deletions

View File

@ -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

View File

@ -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();
});
});
});

View File

@ -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, '');
};

View File

@ -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 = {};

View File

@ -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();

View File

@ -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);
}

View File

@ -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 (
<Stack direction="row" alignItems="center">
<Field
noMargin
label={t('alerting.contact-point-selector.contact-point-picker-label-contact-point', 'Contact point')}
data-testid="contact-point-picker"
>

View File

@ -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

View File

@ -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<ComponentProps<typeof TextLink>, 'href' | 'children'> {
@ -11,9 +13,11 @@ interface ContactPointLinkProps extends Omit<ComponentProps<typeof TextLink>, '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

View File

@ -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<ComGithubGrafanaGrafanaPkgApisAlertingNo
{
metadata: {
annotations: {
[PROVENANCE_ANNOTATION]: PROVENANCE_NONE,
[K8sAnnotations.Provenance]: PROVENANCE_NONE,
},
name: TIME_INTERVAL_UID_HAPPY_PATH,
name: base64UrlEncode(TIME_INTERVAL_NAME_HAPPY_PATH),
uid: TIME_INTERVAL_UID_HAPPY_PATH,
namespace: 'default',
resourceVersion: 'e0270bfced786660',
@ -32,9 +33,9 @@ const allTimeIntervals = getK8sResponse<ComGithubGrafanaGrafanaPkgApisAlertingNo
{
metadata: {
annotations: {
[PROVENANCE_ANNOTATION]: 'file',
[K8sAnnotations.Provenance]: 'file',
},
name: TIME_INTERVAL_UID_FILE_PROVISIONED,
name: base64UrlEncode(TIME_INTERVAL_NAME_FILE_PROVISIONED),
uid: TIME_INTERVAL_UID_FILE_PROVISIONED,
namespace: 'default',
resourceVersion: 'a76d2fcc6731aa0c',
@ -65,11 +66,11 @@ export const listNamespacedTimeIntervalHandler = () =>
);
}
// 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 });

View File

@ -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<PathParams, DefaultBodyType, PromRulesResponse> {
const orderedGroupsWithCursor = groups.map((group) => ({
...group,
id: Buffer.from(`${group.file}-${group.name}`).toString('base64url'),
id: base64UrlEncode(`${group.file}-${group.name}`),
}));
return ({ request }) => {

View File

@ -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(',');
};