mirror of https://github.com/grafana/grafana.git
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:
parent
d0f79ee60d
commit
43be84076c
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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, '');
|
||||
};
|
||||
|
|
|
@ -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 = {};
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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(',');
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue