Alerting: Use useProduceNewAlertmanagerConfiguration for notification policies (#98615)

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Sonia Aguilar
2025-01-17 20:06:50 +02:00
committed by GitHub
co-authored by Gilles De Mey
parent 59184628e1
commit 798b32138d
17 changed files with 1039 additions and 207 deletions
@@ -1,11 +0,0 @@
import { generatedRoutesApi } from 'app/features/alerting/unified/openapi/routesApi.gen';
export const routingTreeApi = generatedRoutesApi.enhanceEndpoints({
endpoints: {
replaceNamespacedRoutingTree: {
// Stop a failed mutation from invalidating the cache, as otherwise the notification policies
// components will re-attach IDs to the routes, and then the user can't update the route anyway
invalidatesTags: (_, error) => (error ? [] : ['RoutingTree']),
},
},
});
@@ -7,7 +7,6 @@ import { AlertState, AlertmanagerGroup, ObjectMatcher, RouteWithID } from 'app/p
import { FormAmRoute } from '../../types/amroutes'; import { FormAmRoute } from '../../types/amroutes';
import { MatcherFormatter } from '../../utils/matchers'; import { MatcherFormatter } from '../../utils/matchers';
import { stringifyErrorLike } from '../../utils/misc';
import { InsertPosition } from '../../utils/routeTree'; import { InsertPosition } from '../../utils/routeTree';
import { AlertGroup } from '../alert-groups/AlertGroup'; import { AlertGroup } from '../alert-groups/AlertGroup';
@@ -20,9 +19,8 @@ import { NotificationPoliciesErrorAlert } from './PolicyUpdateErrorAlert';
type ModalHook<T = undefined> = [JSX.Element, (item: T) => void, () => void]; type ModalHook<T = undefined> = [JSX.Element, (item: T) => void, () => void];
type AddModalHook<T = undefined> = [JSX.Element, (item: T, position: InsertPosition) => void, () => void]; type AddModalHook<T = undefined> = [JSX.Element, (item: T, position: InsertPosition) => void, () => void];
type EditModalHook = [JSX.Element, (item: RouteWithID, isDefaultRoute?: boolean) => void, () => void]; type EditModalHook = [JSX.Element, (item: RouteWithID, isDefaultRoute?: boolean) => void, () => void];
const useAddPolicyModal = ( const useAddPolicyModal = (
handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => void, handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => Promise<void>,
loading: boolean loading: boolean
): AddModalHook<RouteWithID> => { ): AddModalHook<RouteWithID> => {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
@@ -32,6 +30,7 @@ const useAddPolicyModal = (
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
setReferenceRoute(undefined); setReferenceRoute(undefined);
setInsertPosition(undefined); setInsertPosition(undefined);
setError(undefined);
setShowModal(false); setShowModal(false);
}, []); }, []);
@@ -41,6 +40,8 @@ const useAddPolicyModal = (
setShowModal(true); setShowModal(true);
}, []); }, []);
const [error, setError] = useState<Error | undefined>(undefined);
const modalElement = useMemo( const modalElement = useMemo(
() => () =>
loading ? ( loading ? (
@@ -53,13 +54,14 @@ const useAddPolicyModal = (
closeOnEscape={true} closeOnEscape={true}
title="Add notification policy" title="Add notification policy"
> >
{error && <NotificationPoliciesErrorAlert error={error} />}
<AmRoutesExpandedForm <AmRoutesExpandedForm
defaults={{ defaults={{
groupBy: referenceRoute?.group_by, groupBy: referenceRoute?.group_by,
}} }}
onSubmit={(newRoute) => { onSubmit={(newRoute) => {
if (referenceRoute && insertPosition) { if (referenceRoute && insertPosition) {
handleAdd(newRoute, referenceRoute, insertPosition); handleAdd(newRoute, referenceRoute, insertPosition).catch(setError);
} }
}} }}
actionButtons={ actionButtons={
@@ -75,7 +77,7 @@ const useAddPolicyModal = (
/> />
</Modal> </Modal>
), ),
[handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal] [error, handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal, setError]
); );
return [modalElement, handleShow, handleDismiss]; return [modalElement, handleShow, handleDismiss];
@@ -83,17 +85,19 @@ const useAddPolicyModal = (
const useEditPolicyModal = ( const useEditPolicyModal = (
alertManagerSourceName: string, alertManagerSourceName: string,
handleSave: (route: Partial<FormAmRoute>) => void, handleUpdate: (route: Partial<FormAmRoute>) => Promise<void>,
loading: boolean, loading: boolean
error?: Error
): EditModalHook => { ): EditModalHook => {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [isDefaultPolicy, setIsDefaultPolicy] = useState(false); const [isDefaultPolicy, setIsDefaultPolicy] = useState(false);
const [route, setRoute] = useState<RouteWithID>(); const [route, setRoute] = useState<RouteWithID>();
const [error, setError] = useState<Error | undefined>(undefined);
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
setRoute(undefined); setRoute(undefined);
setShowModal(false); setShowModal(false);
setError(undefined);
}, []); }, []);
const handleShow = useCallback((route: RouteWithID, isDefaultPolicy?: boolean) => { const handleShow = useCallback((route: RouteWithID, isDefaultPolicy?: boolean) => {
@@ -114,13 +118,13 @@ const useEditPolicyModal = (
closeOnEscape={true} closeOnEscape={true}
title="Edit notification policy" title="Edit notification policy"
> >
{error && <NotificationPoliciesErrorAlert error={stringifyErrorLike(error)} />} {error && <NotificationPoliciesErrorAlert error={error} />}
{isDefaultPolicy && route && ( {isDefaultPolicy && route && (
<AmRootRouteForm <AmRootRouteForm
// TODO *sigh* this alertmanagersourcename should come from context or something // TODO *sigh* this alertmanagersourcename should come from context or something
// passing it down all the way here is a code smell // passing it down all the way here is a code smell
alertManagerSourceName={alertManagerSourceName} alertManagerSourceName={alertManagerSourceName}
onSubmit={handleSave} onSubmit={(values) => handleUpdate(values).catch(setError)}
route={route} route={route}
actionButtons={ actionButtons={
<Modal.ButtonRow> <Modal.ButtonRow>
@@ -137,7 +141,7 @@ const useEditPolicyModal = (
{!isDefaultPolicy && ( {!isDefaultPolicy && (
<AmRoutesExpandedForm <AmRoutesExpandedForm
route={route} route={route}
onSubmit={handleSave} onSubmit={(values) => handleUpdate(values).catch(setError)}
actionButtons={ actionButtons={
<Modal.ButtonRow> <Modal.ButtonRow>
<Button type="button" variant="secondary" onClick={handleDismiss} fill="outline"> <Button type="button" variant="secondary" onClick={handleDismiss} fill="outline">
@@ -152,19 +156,24 @@ const useEditPolicyModal = (
)} )}
</Modal> </Modal>
), ),
[alertManagerSourceName, error, handleDismiss, handleSave, isDefaultPolicy, loading, route, showModal] [loading, showModal, handleDismiss, error, isDefaultPolicy, route, alertManagerSourceName, handleUpdate, setError]
); );
return [modalElement, handleShow, handleDismiss]; return [modalElement, handleShow, handleDismiss];
}; };
const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loading: boolean): ModalHook<RouteWithID> => { const useDeletePolicyModal = (
handleDelete: (route: RouteWithID) => Promise<void>,
loading: boolean
): ModalHook<RouteWithID> => {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [route, setRoute] = useState<RouteWithID>(); const [route, setRoute] = useState<RouteWithID>();
const [error, setError] = useState<Error | undefined>(undefined);
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
setRoute(undefined); setRoute(undefined);
setShowModal(false); setShowModal(false);
setError(undefined);
}, [setRoute]); }, [setRoute]);
const handleShow = useCallback((route: RouteWithID) => { const handleShow = useCallback((route: RouteWithID) => {
@@ -172,12 +181,6 @@ const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loadin
setShowModal(true); setShowModal(true);
}, []); }, []);
const handleSubmit = useCallback(() => {
if (route) {
handleDelete(route);
}
}, [handleDelete, route]);
const modalElement = useMemo( const modalElement = useMemo(
() => () =>
loading ? ( loading ? (
@@ -190,13 +193,13 @@ const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loadin
closeOnEscape={true} closeOnEscape={true}
title="Delete notification policy" title="Delete notification policy"
> >
{error && <NotificationPoliciesErrorAlert error={error} />}
<Trans i18nKey="alerting.policies.delete.warning-1"> <Trans i18nKey="alerting.policies.delete.warning-1">
Deleting this notification policy will permanently remove it. Deleting this notification policy will permanently remove it.
</Trans> </Trans>{' '}
<Trans i18nKey="alerting.policies.delete.warning-2">Are you sure you want to delete this policy?</Trans> <Trans i18nKey="alerting.policies.delete.warning-2">Are you sure you want to delete this policy?</Trans>
<Modal.ButtonRow> <Modal.ButtonRow>
<Button type="button" variant="destructive" onClick={handleSubmit}> <Button type="button" variant="destructive" onClick={() => route && handleDelete(route).catch(setError)}>
<Trans i18nKey="alerting.policies.delete.confirm">Yes, delete policy</Trans> <Trans i18nKey="alerting.policies.delete.confirm">Yes, delete policy</Trans>
</Button> </Button>
<Button type="button" variant="secondary" onClick={handleDismiss}> <Button type="button" variant="secondary" onClick={handleDismiss}>
@@ -205,7 +208,7 @@ const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loadin
</Modal.ButtonRow> </Modal.ButtonRow>
</Modal> </Modal>
), ),
[handleDismiss, handleSubmit, loading, showModal] [handleDismiss, loading, showModal, error, route, handleDelete]
); );
return [modalElement, handleShow, handleDismiss]; return [modalElement, handleShow, handleDismiss];
@@ -8,28 +8,27 @@ import { useContactPointsWithStatus } from 'app/features/alerting/unified/compon
import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities'; import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
import { FormAmRoute } from 'app/features/alerting/unified/types/amroutes'; import { FormAmRoute } from 'app/features/alerting/unified/types/amroutes';
import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes'; import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes';
import { ERROR_NEWER_CONFIGURATION } from 'app/features/alerting/unified/utils/k8s/errors'; import { getErrorCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
import { isErrorMatchingCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
import { computeInheritedTree } from 'app/features/alerting/unified/utils/notification-policies'; import { computeInheritedTree } from 'app/features/alerting/unified/utils/notification-policies';
import { ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { ObjectMatcher, ROUTES_META_SYMBOL, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { anyOfRequestState, isError } from '../../hooks/useAsync';
import { useAlertmanager } from '../../state/AlertmanagerContext'; import { useAlertmanager } from '../../state/AlertmanagerContext';
import { ERROR_NEWER_CONFIGURATION } from '../../utils/k8s/errors';
import { alertmanagerApi } from './../../api/alertmanagerApi'; import { alertmanagerApi } from './../../api/alertmanagerApi';
import { useGetContactPointsState } from './../../api/receiversApi'; import { useGetContactPointsState } from './../../api/receiversApi';
import { isLoading as isPending, useAsync } from './../../hooks/useAsync';
import { useRouteGroupsMatcher } from './../../useRouteGroupsMatcher'; import { useRouteGroupsMatcher } from './../../useRouteGroupsMatcher';
import { import { InsertPosition } from './../../utils/routeTree';
InsertPosition,
addRouteToReferenceRoute,
cleanRouteIDs,
mergePartialAmRouteWithRouteTree,
omitRouteFromRouteTree,
} from './../../utils/routeTree';
import { NotificationPoliciesFilter, findRoutesByMatchers, findRoutesMatchingPredicate } from './Filters'; import { NotificationPoliciesFilter, findRoutesByMatchers, findRoutesMatchingPredicate } from './Filters';
import { useAddPolicyModal, useAlertGroupsModal, useDeletePolicyModal, useEditPolicyModal } from './Modals'; import { useAddPolicyModal, useAlertGroupsModal, useDeletePolicyModal, useEditPolicyModal } from './Modals';
import { Policy } from './Policy'; import { Policy } from './Policy';
import { useNotificationPolicyRoute, useUpdateNotificationPolicyRoute } from './useNotificationPolicyRoute'; import {
useAddNotificationPolicy,
useDeleteNotificationPolicy,
useNotificationPolicyRoute,
useUpdateExistingNotificationPolicy,
} from './useNotificationPolicyRoute';
export const NotificationPoliciesList = () => { export const NotificationPoliciesList = () => {
const appNotification = useAppNotification(); const appNotification = useAppNotification();
@@ -53,7 +52,7 @@ export const NotificationPoliciesList = () => {
const { const {
currentData, currentData,
isLoading, isLoading,
error: resultError, error: fetchPoliciesError,
refetch: refetchNotificationPolicyRoute, refetch: refetchNotificationPolicyRoute,
} = useNotificationPolicyRoute({ alertmanager: selectedAlertmanager ?? '' }); } = useNotificationPolicyRoute({ alertmanager: selectedAlertmanager ?? '' });
@@ -62,7 +61,22 @@ export const NotificationPoliciesList = () => {
// TODO in the future: Generalise the component to support any number of "root" policies // TODO in the future: Generalise the component to support any number of "root" policies
const [defaultPolicy] = currentData ?? []; const [defaultPolicy] = currentData ?? [];
const updateNotificationPolicyRoute = useUpdateNotificationPolicyRoute(selectedAlertmanager ?? ''); // deleting policies
const [deleteNotificationPolicy, deleteNotificationPolicyState] = useDeleteNotificationPolicy({
alertmanager: selectedAlertmanager ?? '',
});
// updating policies
const [updateExistingNotificationPolicy, updateExistingNotificationPolicyState] = useUpdateExistingNotificationPolicy(
{
alertmanager: selectedAlertmanager ?? '',
}
);
// adding new policies
const [addNotificationPolicy, addNotificationPolicyState] = useAddNotificationPolicy({
alertmanager: selectedAlertmanager ?? '',
});
const { currentData: alertGroups, refetch: refetchAlertGroups } = useGetAlertmanagerAlertGroupsQuery( const { currentData: alertGroups, refetch: refetchAlertGroups } = useGetAlertmanagerAlertGroupsQuery(
{ amSourceName: selectedAlertmanager ?? '' }, { amSourceName: selectedAlertmanager ?? '' },
@@ -112,56 +126,38 @@ export const NotificationPoliciesList = () => {
const refetchPolicies = () => { const refetchPolicies = () => {
refetchNotificationPolicyRoute(); refetchNotificationPolicyRoute();
updateRouteTree.reset(); updateExistingNotificationPolicy.reset();
deleteNotificationPolicy.reset();
addNotificationPolicy.reset();
}; };
function handleSave(partialRoute: Partial<FormAmRoute>) { async function handleUpdate(partialRoute: Partial<FormAmRoute>) {
if (!rootRoute) { await updateExistingNotificationPolicy.execute(partialRoute);
return; handleActionResult({ error: updateExistingNotificationPolicyState.error });
}
const newRouteTree = mergePartialAmRouteWithRouteTree(selectedAlertmanager ?? '', partialRoute, rootRoute);
updateRouteTree.execute(newRouteTree);
} }
function handleDelete(route: RouteWithID) { async function handleDelete(route: RouteWithID) {
if (!rootRoute) { await deleteNotificationPolicy.execute(route.id);
return; handleActionResult({ error: deleteNotificationPolicyState.error });
}
const newRouteTree = omitRouteFromRouteTree(route, rootRoute);
updateRouteTree.execute(newRouteTree);
} }
function handleAdd(partialRoute: Partial<FormAmRoute>, referenceRoute: RouteWithID, insertPosition: InsertPosition) { async function handleAdd(
if (!rootRoute) { partialRoute: Partial<FormAmRoute>,
return; referenceRoute: RouteWithID,
} insertPosition: InsertPosition
) {
const newRouteTree = addRouteToReferenceRoute( await addNotificationPolicy.execute({
selectedAlertmanager ?? '',
partialRoute, partialRoute,
referenceRoute, referenceRouteIdentifier: referenceRoute.id,
rootRoute, insertPosition,
insertPosition });
); handleActionResult({ error: addNotificationPolicyState.error });
updateRouteTree.execute(newRouteTree);
} }
// this function will make the HTTP request and tracks the state of the request function handleActionResult({ error }: { error?: Error }) {
const [updateRouteTree, updateRouteTreeState] = useAsync(async (routeTree: Route | RouteWithID) => { if (!error) {
if (!rootRoute) { appNotification.success('Updated notification policies');
return;
} }
// make sure we omit all IDs from our routes
const newRouteTree = cleanRouteIDs(routeTree);
const newTree = await updateNotificationPolicyRoute({
newRoute: newRouteTree,
oldRoute: defaultPolicy,
});
appNotification.success('Updated notification policies');
if (selectedAlertmanager) { if (selectedAlertmanager) {
refetchAlertGroups(); refetchAlertGroups();
} }
@@ -170,20 +166,20 @@ export const NotificationPoliciesList = () => {
closeEditModal(); closeEditModal();
closeAddModal(); closeAddModal();
closeDeleteModal(); closeDeleteModal();
}
return newTree; const updatingTree = anyOfRequestState(
}); updateExistingNotificationPolicyState,
deleteNotificationPolicyState,
const updatingTree = isPending(updateRouteTreeState); addNotificationPolicyState
const updateError = updateRouteTreeState.error; ).loading;
// edit, add, delete modals // edit, add, delete modals
const [addModal, openAddModal, closeAddModal] = useAddPolicyModal(handleAdd, updatingTree); const [addModal, openAddModal, closeAddModal] = useAddPolicyModal(handleAdd, updatingTree);
const [editModal, openEditModal, closeEditModal] = useEditPolicyModal( const [editModal, openEditModal, closeEditModal] = useEditPolicyModal(
selectedAlertmanager ?? '', selectedAlertmanager ?? '',
handleSave, handleUpdate,
updatingTree, updatingTree
updateError
); );
const [deleteModal, openDeleteModal, closeDeleteModal] = useDeletePolicyModal(handleDelete, updatingTree); const [deleteModal, openDeleteModal, closeDeleteModal] = useDeletePolicyModal(handleDelete, updatingTree);
const [alertInstancesModal, showAlertGroupsModal] = useAlertGroupsModal(selectedAlertmanager ?? ''); const [alertInstancesModal, showAlertGroupsModal] = useAlertGroupsModal(selectedAlertmanager ?? '');
@@ -192,18 +188,23 @@ export const NotificationPoliciesList = () => {
return null; return null;
} }
const hasPoliciesData = rootRoute && !resultError && !isLoading; const hasPoliciesData = rootRoute && !fetchPoliciesError && !isLoading;
const hasPoliciesError = !!resultError && !isLoading; const hasPoliciesError = Boolean(fetchPoliciesError) && !isLoading;
const hasConflictError = [
addNotificationPolicyState,
updateExistingNotificationPolicyState,
deleteNotificationPolicyState,
].some((state) => isError(state) && getErrorCode(state.error) === ERROR_NEWER_CONFIGURATION);
return ( return (
<> <>
{hasPoliciesError && ( {hasPoliciesError && (
<Alert severity="error" title="Error loading Alertmanager config"> <Alert severity="error" title="Error loading Alertmanager config">
{stringifyErrorLike(resultError) || 'Unknown error.'} {stringifyErrorLike(fetchPoliciesError) || 'Unknown error.'}
</Alert> </Alert>
)} )}
{/* show when there is an update error */} {/* show when there is an update error */}
{isErrorMatchingCode(updateError, ERROR_NEWER_CONFIGURATION) && ( {hasConflictError && (
<Alert severity="info" title="Notification policies have changed"> <Alert severity="info" title="Notification policies have changed">
<Stack direction="row" justifyContent="space-between" alignItems="center"> <Stack direction="row" justifyContent="space-between" alignItems="center">
<Trans i18nKey="alerting.policies.update-errors.conflict"> <Trans i18nKey="alerting.policies.update-errors.conflict">
@@ -230,7 +231,7 @@ export const NotificationPoliciesList = () => {
currentRoute={rootRoute} currentRoute={rootRoute}
contactPointsState={contactPointsState.receivers} contactPointsState={contactPointsState.receivers}
readOnly={!hasConfigurationAPI} readOnly={!hasConfigurationAPI}
provisioned={rootRoute._metadata?.provisioned} provisioned={rootRoute[ROUTES_META_SYMBOL]?.provisioned}
alertManagerSourceName={selectedAlertmanager} alertManagerSourceName={selectedAlertmanager}
onAddPolicy={openAddModal} onAddPolicy={openAddModal}
onEditPolicy={openEditModal} onEditPolicy={openEditModal}
@@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createKubernetesRoutingTreeSpec 1`] = `
{
"metadata": {
"name": "user-defined",
"resourceVersion": "abc123",
},
"spec": {
"defaults": {
"group_by": [
"alertname",
],
"receiver": "default-receiver",
"repeat_interval": "4h",
},
"routes": [
{
"continue": false,
"group_interval": "5m",
"group_wait": "30s",
"matchers": [
{
"label": "team",
"type": "=",
"value": "frontend",
},
],
"receiver": "nested-receiver",
"routes": undefined,
},
],
},
}
`;
@@ -0,0 +1,117 @@
import { MatcherOperator, ROUTES_META_SYMBOL, Route } from 'app/plugins/datasource/alertmanager/types';
import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route } from '../../openapi/routesApi.gen';
import { ROOT_ROUTE_NAME } from '../../utils/k8s/constants';
import { createKubernetesRoutingTreeSpec, k8sSubRouteToRoute, routeToK8sSubRoute } from './useNotificationPolicyRoute';
test('k8sSubRouteToRoute', () => {
const input: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route = {
continue: false,
group_by: ['label1'],
group_interval: '5m',
group_wait: '30s',
matchers: [{ label: 'label1', type: '=', value: 'value1' }],
mute_time_intervals: ['mt-1'],
receiver: 'my-receiver',
repeat_interval: '4h',
routes: [
{
receiver: 'receiver2',
matchers: [{ label: 'label2', type: '!=', value: 'value2' }],
},
],
};
const expected: Route = {
continue: false,
group_by: ['label1'],
group_interval: '5m',
group_wait: '30s',
matchers: undefined, // matchers -> object_matchers
object_matchers: [['label1', MatcherOperator.equal, 'value1']],
mute_time_intervals: ['mt-1'],
receiver: 'my-receiver',
repeat_interval: '4h',
routes: [
{
receiver: 'receiver2',
matchers: undefined,
object_matchers: [['label2', MatcherOperator.notEqual, 'value2']],
routes: undefined,
},
],
};
expect(k8sSubRouteToRoute(input)).toStrictEqual(expected);
});
test('routeToK8sSubRoute', () => {
const input: Route = {
continue: false,
group_by: ['label1'],
group_interval: '5m',
group_wait: '30s',
matchers: undefined, // matchers -> object_matchers
object_matchers: [['label1', MatcherOperator.equal, 'value1']],
mute_time_intervals: ['mt-1'],
receiver: 'my-receiver',
repeat_interval: '4h',
routes: [
{
receiver: 'receiver2',
matchers: undefined,
object_matchers: [['label2', MatcherOperator.notEqual, 'value2']],
},
],
};
const expected: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route = {
continue: false,
group_by: ['label1'],
group_interval: '5m',
group_wait: '30s',
matchers: [{ label: 'label1', type: '=', value: 'value1' }],
mute_time_intervals: ['mt-1'],
receiver: 'my-receiver',
repeat_interval: '4h',
routes: [
{
receiver: 'receiver2',
matchers: [{ label: 'label2', type: '!=', value: 'value2' }],
routes: undefined,
},
],
};
expect(routeToK8sSubRoute(input)).toStrictEqual(expected);
});
test('createKubernetesRoutingTreeSpec', () => {
const route: Route = {
continue: true,
group_by: ['alertname'],
matchers: undefined,
object_matchers: [['severity', MatcherOperator.equal, 'critical']],
mute_time_intervals: ['interval-1'],
receiver: 'default-receiver',
repeat_interval: '4h',
routes: [
{
continue: false,
receiver: 'nested-receiver',
object_matchers: [['team', MatcherOperator.equal, 'frontend']],
group_wait: '30s',
group_interval: '5m',
},
],
[ROUTES_META_SYMBOL]: {
resourceVersion: 'abc123',
},
};
const tree = createKubernetesRoutingTreeSpec(route);
expect(tree.metadata.name).toBe(ROOT_ROUTE_NAME);
expect(tree).toMatchSnapshot();
});
@@ -1,27 +1,45 @@
import { pick } from 'lodash';
import memoize from 'micro-memoize'; import memoize from 'micro-memoize';
import { routingTreeApi } from 'app/features/alerting/unified/api/notificationPoliciesApi';
import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks'; import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks';
import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; import { MatcherOperator, ROUTES_META_SYMBOL, Route } from 'app/plugins/datasource/alertmanager/types';
import { alertmanagerApi } from '../../api/alertmanagerApi'; import { alertmanagerApi } from '../../api/alertmanagerApi';
import { useAsync } from '../../hooks/useAsync';
import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig';
import { import {
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route, ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults,
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree, ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree,
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTreeSpec, generatedRoutesApi as routingTreeApi,
} from '../../openapi/routesApi.gen'; } from '../../openapi/routesApi.gen';
import {
addRouteAction,
deleteRouteAction,
updateRouteAction,
} from '../../reducers/alertmanager/notificationPolicyRoutes';
import { FormAmRoute } from '../../types/amroutes';
import { addUniqueIdentifierToRoute } from '../../utils/amroutes';
import { PROVENANCE_NONE, ROOT_ROUTE_NAME } from '../../utils/k8s/constants'; import { PROVENANCE_NONE, ROOT_ROUTE_NAME } from '../../utils/k8s/constants';
import { ERROR_NEWER_CONFIGURATION } from '../../utils/k8s/errors';
import { getK8sNamespace, isK8sEntityProvisioned, shouldUseK8sApi } from '../../utils/k8s/utils'; import { getK8sNamespace, isK8sEntityProvisioned, shouldUseK8sApi } from '../../utils/k8s/utils';
import { INHERITABLE_KEYS, InheritableProperties } from '../../utils/notification-policies';
import {
InsertPosition,
addRouteToReferenceRoute,
cleanKubernetesRouteIDs,
mergePartialAmRouteWithRouteTree,
omitRouteFromRouteTree,
} from '../../utils/routeTree';
const k8sRoutesToRoutesMemoized = memoize(k8sRoutesToRoutes, { maxSize: 1 }); const k8sRoutesToRoutesMemoized = memoize(k8sRoutesToRoutes, { maxSize: 1 });
const { useListNamespacedRoutingTreeQuery, useReplaceNamespacedRoutingTreeMutation } = routingTreeApi;
const { const {
useUpdateAlertmanagerConfigurationMutation, useListNamespacedRoutingTreeQuery,
useLazyGetAlertmanagerConfigurationQuery, useReplaceNamespacedRoutingTreeMutation,
useGetAlertmanagerConfigurationQuery, useLazyListNamespacedRoutingTreeQuery,
} = alertmanagerApi; } = routingTreeApi;
const { useGetAlertmanagerConfigurationQuery } = alertmanagerApi;
export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArgs, { skip }: Skippable = {}) => { export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArgs, { skip }: Skippable = {}) => {
const k8sApiSupported = shouldUseK8sApi(alertmanager); const k8sApiSupported = shouldUseK8sApi(alertmanager);
@@ -61,73 +79,149 @@ export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArg
const parseAmConfigRoute = memoize((route: Route): Route => { const parseAmConfigRoute = memoize((route: Route): Route => {
return { return {
...route, ...route,
_metadata: { provisioned: Boolean(route.provenance && route.provenance !== PROVENANCE_NONE) }, [ROUTES_META_SYMBOL]: {
provisioned: Boolean(route.provenance && route.provenance !== PROVENANCE_NONE),
},
}; };
}); });
export function useUpdateNotificationPolicyRoute(selectedAlertmanager: string) { export function useUpdateExistingNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) {
const [getAlertmanagerConfiguration] = useLazyGetAlertmanagerConfigurationQuery(); const k8sApiSupported = shouldUseK8sApi(alertmanager);
const [updateAlertmanagerConfiguration] = useUpdateAlertmanagerConfigurationMutation();
const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation(); const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation();
const [produceNewAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
const [listNamespacedRoutingTree] = useLazyListNamespacedRoutingTreeQuery();
const k8sApiSupported = shouldUseK8sApi(selectedAlertmanager); const updateUsingK8sApi = useAsync(async (update: Partial<FormAmRoute>) => {
async function updateUsingK8sApi({ newRoute }: { newRoute: Route }) {
const namespace = getK8sNamespace(); const namespace = getK8sNamespace();
const { routes, _metadata, ...defaults } = newRoute; const result = await listNamespacedRoutingTree({ namespace });
// Remove provenance so we don't send it to API
// Convert Route to K8s compatible format const [rootTree] = result.data ? k8sRoutesToRoutesMemoized(result.data.items) : [];
const k8sRoute: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTreeSpec = { if (!rootTree) {
defaults: { throw new Error(`no root route found for namespace ${namespace}`);
...defaults, }
// TODO: Fix types in k8s API? Fix our types to not allow empty receiver? TBC
receiver: defaults.receiver || '', const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(rootTree);
}, const newRouteTree = mergePartialAmRouteWithRouteTree(alertmanager, update, rootRouteWithIdentifiers);
routes: newRoute.routes?.map(routeToK8sSubRoute) || [],
};
// Create the K8s route object // Create the K8s route object
const routeObject: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree = { const routeObject = createKubernetesRoutingTreeSpec(newRouteTree);
spec: k8sRoute,
metadata: { name: ROOT_ROUTE_NAME, resourceVersion: _metadata?.resourceVersion }, return updatedNamespacedRoute({
}; name: ROOT_ROUTE_NAME,
namespace,
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: cleanKubernetesRouteIDs(routeObject),
}).unwrap();
});
const updateFromAlertmanagerConfiguration = useAsync(async (update: Partial<FormAmRoute>) => {
const action = updateRouteAction({ update, alertmanager });
return produceNewAlertmanagerConfiguration(action);
});
return k8sApiSupported ? updateUsingK8sApi : updateFromAlertmanagerConfiguration;
}
export function useDeleteNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) {
const k8sApiSupported = shouldUseK8sApi(alertmanager);
const [produceNewAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
const [listNamespacedRoutingTree] = useLazyListNamespacedRoutingTreeQuery();
const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation();
const deleteFromK8sApi = useAsync(async (id: string) => {
const namespace = getK8sNamespace();
const result = await listNamespacedRoutingTree({ namespace });
const [rootTree] = result.data ? k8sRoutesToRoutesMemoized(result.data.items) : [];
if (!rootTree) {
throw new Error(`no root route found for namespace ${namespace}`);
}
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(rootTree);
const newRouteTree = omitRouteFromRouteTree(id, rootRouteWithIdentifiers);
// Create the K8s route object
const routeObject = createKubernetesRoutingTreeSpec(newRouteTree);
return updatedNamespacedRoute({ return updatedNamespacedRoute({
name: ROOT_ROUTE_NAME, name: ROOT_ROUTE_NAME,
namespace, namespace,
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: routeObject, comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: routeObject,
}).unwrap(); }).unwrap();
} });
async function updateUsingConfigFileApi({ newRoute, oldRoute }: { newRoute: Route; oldRoute: Route }) { const deleteFromAlertmanagerConfiguration = useAsync(async (id: string) => {
const { _metadata, ...oldRouteStripped } = oldRoute; const action = deleteRouteAction({ id });
const { _metadata: newMetadata, ...newRouteStripped } = newRoute; return produceNewAlertmanagerConfiguration(action);
const lastConfig = await getAlertmanagerConfiguration(selectedAlertmanager).unwrap(); });
const latestRouteFromConfig = lastConfig.alertmanager_config.route;
const configChangedInMeantime = JSON.stringify(oldRouteStripped) !== JSON.stringify(latestRouteFromConfig); return k8sApiSupported ? deleteFromK8sApi : deleteFromAlertmanagerConfiguration;
}
if (configChangedInMeantime) { export function useAddNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) {
throw new Error('configuration modification conflict', { cause: ERROR_NEWER_CONFIGURATION }); const k8sApiSupported = shouldUseK8sApi(alertmanager);
const [produceNewAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
const [listNamespacedRoutingTree] = useLazyListNamespacedRoutingTreeQuery();
const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation();
const addToK8sApi = useAsync(
async ({
partialRoute,
referenceRouteIdentifier,
insertPosition,
}: {
partialRoute: Partial<FormAmRoute>;
referenceRouteIdentifier: string;
insertPosition: InsertPosition;
}) => {
const namespace = getK8sNamespace();
const result = await listNamespacedRoutingTree({ namespace });
const [rootTree] = result.data ? k8sRoutesToRoutesMemoized(result.data.items) : [];
if (!rootTree) {
throw new Error(`no root route found for namespace ${namespace}`);
}
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(rootTree);
const newRouteTree = addRouteToReferenceRoute(
alertmanager ?? '',
partialRoute,
referenceRouteIdentifier,
rootRouteWithIdentifiers,
insertPosition
);
// Create the K8s route object
const routeObject = createKubernetesRoutingTreeSpec(newRouteTree);
return updatedNamespacedRoute({
name: ROOT_ROUTE_NAME,
namespace,
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: cleanKubernetesRouteIDs(routeObject),
}).unwrap();
} }
);
const newConfig = { const addToAlertmanagerConfiguration = useAsync(
...lastConfig, async ({
alertmanager_config: { partialRoute,
...lastConfig.alertmanager_config, referenceRouteIdentifier,
route: newRouteStripped, insertPosition,
}, }: {
}; partialRoute: Partial<FormAmRoute>;
referenceRouteIdentifier: string;
insertPosition: InsertPosition;
}) => {
const action = addRouteAction({
partialRoute,
referenceRouteIdentifier,
insertPosition,
alertmanager,
});
return produceNewAlertmanagerConfiguration(action);
}
);
// TODO This needs to properly handle lazy AM initialization return k8sApiSupported ? addToK8sApi : addToAlertmanagerConfiguration;
return updateAlertmanagerConfiguration({
selectedAlertmanager,
config: newConfig,
}).unwrap();
}
return k8sApiSupported ? updateUsingK8sApi : updateUsingConfigFileApi;
} }
function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree[]): Route[] { function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree[]): Route[] {
@@ -135,7 +229,7 @@ function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotific
return { return {
...route.spec.defaults, ...route.spec.defaults,
routes: route.spec.routes?.map(k8sSubRouteToRoute), routes: route.spec.routes?.map(k8sSubRouteToRoute),
_metadata: { [ROUTES_META_SYMBOL]: {
provisioned: isK8sEntityProvisioned(route), provisioned: isK8sEntityProvisioned(route),
resourceVersion: route.metadata.resourceVersion, resourceVersion: route.metadata.resourceVersion,
name: route.metadata.name, name: route.metadata.name,
@@ -149,7 +243,7 @@ function isValidMatcherOperator(type: string): type is MatcherOperator {
return Object.values<string>(MatcherOperator).includes(type); return Object.values<string>(MatcherOperator).includes(type);
} }
function k8sSubRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route): Route { export function k8sSubRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route): Route {
return { return {
...route, ...route,
routes: route.routes?.map(k8sSubRouteToRoute), routes: route.routes?.map(k8sSubRouteToRoute),
@@ -163,7 +257,7 @@ function k8sSubRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotific
}; };
} }
function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route { export function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route {
const { object_matchers, ...rest } = route; const { object_matchers, ...rest } = route;
return { return {
...rest, ...rest,
@@ -176,3 +270,34 @@ function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApisAlertin
routes: route.routes?.map(routeToK8sSubRoute), routes: route.routes?.map(routeToK8sSubRoute),
}; };
} }
/**
* Convert Route to K8s compatible format. Make sure we aren't sending any additional properties the API doesn't recognize
* because it will reply with excess properties in the HTTP headers
*/
export function createKubernetesRoutingTreeSpec(
rootRoute: Route
): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree {
const inheritableDefaultProperties: InheritableProperties = pick(rootRoute, INHERITABLE_KEYS);
const defaults: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults = {
...inheritableDefaultProperties,
// TODO: Fix types in k8s API? Fix our types to not allow empty receiver? TBC
receiver: rootRoute.receiver ?? '',
};
const routes = rootRoute.routes?.map(routeToK8sSubRoute) ?? [];
const spec = {
defaults,
routes,
};
return {
spec: spec,
metadata: {
name: ROOT_ROUTE_NAME,
resourceVersion: rootRoute[ROUTES_META_SYMBOL]?.resourceVersion,
},
};
}
@@ -5,6 +5,7 @@ import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/ty
import { alertmanagerApi } from '../api/alertmanagerApi'; import { alertmanagerApi } from '../api/alertmanagerApi';
import { muteTimingsReducer } from '../reducers/alertmanager/muteTimings'; import { muteTimingsReducer } from '../reducers/alertmanager/muteTimings';
import { routesReducer } from '../reducers/alertmanager/notificationPolicyRoutes';
import { notificationTemplatesReducer } from '../reducers/alertmanager/notificationTemplates'; import { notificationTemplatesReducer } from '../reducers/alertmanager/notificationTemplates';
import { receiversReducer } from '../reducers/alertmanager/receivers'; import { receiversReducer } from '../reducers/alertmanager/receivers';
import { useAlertmanager } from '../state/AlertmanagerContext'; import { useAlertmanager } from '../state/AlertmanagerContext';
@@ -31,7 +32,8 @@ const configurationReducer = reduceReducers(
initialAlertmanagerConfiguration, initialAlertmanagerConfiguration,
muteTimingsReducer, muteTimingsReducer,
receiversReducer, receiversReducer,
notificationTemplatesReducer notificationTemplatesReducer,
routesReducer
); );
/** /**
@@ -0,0 +1,182 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`routes Should add a new route with receiver E as a child of default route 1`] = `
{
"alertmanager_config": {
"mute_time_intervals": [],
"route": {
"group_by": [
"grafana_folder",
],
"group_interval": "1m",
"group_wait": "10s",
"object_matchers": [],
"receiver": "ROOT",
"routes": [
{
"object_matchers": [
[
"team",
"=",
"operations",
],
],
"receiver": "A",
"routes": [
{
"object_matchers": [
[
"region",
"=",
"europe",
],
],
"receiver": "B1",
"routes": [],
},
{
"object_matchers": [
[
"region",
"=",
"nasa",
],
],
"receiver": "B2",
"routes": [],
},
],
},
{
"object_matchers": [
[
"foo",
"=",
"bar",
],
],
"receiver": "C",
"routes": [],
},
{
"continue": undefined,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"match": undefined,
"match_re": undefined,
"matchers": undefined,
"mute_time_intervals": undefined,
"object_matchers": undefined,
"receiver": "E",
"repeat_interval": undefined,
"routes": undefined,
},
],
},
"time_intervals": [],
},
"template_files": {},
}
`;
exports[`routes Should delete route 1`] = `
{
"alertmanager_config": {
"mute_time_intervals": [],
"route": {
"group_by": [
"grafana_folder",
],
"group_interval": "1m",
"group_wait": "10s",
"object_matchers": [],
"receiver": "ROOT",
"routes": [
{
"object_matchers": [
[
"foo",
"=",
"bar",
],
],
"receiver": "C",
"routes": [],
},
],
},
"time_intervals": [],
},
"template_files": {},
}
`;
exports[`routes Should update an existing route A with receiver B 1`] = `
{
"alertmanager_config": {
"mute_time_intervals": [],
"route": {
"group_by": [
"grafana_folder",
],
"group_interval": "1m",
"group_wait": "10s",
"object_matchers": [],
"receiver": "ROOT",
"routes": [
{
"continue": undefined,
"group_by": undefined,
"group_interval": undefined,
"group_wait": undefined,
"match": undefined,
"match_re": undefined,
"matchers": undefined,
"mute_time_intervals": undefined,
"object_matchers": undefined,
"receiver": "B",
"repeat_interval": undefined,
"routes": [
{
"object_matchers": [
[
"region",
"=",
"europe",
],
],
"receiver": "B1",
"routes": [],
},
{
"object_matchers": [
[
"region",
"=",
"nasa",
],
],
"receiver": "B2",
"routes": [],
},
],
},
{
"object_matchers": [
[
"foo",
"=",
"bar",
],
],
"receiver": "C",
"routes": [],
},
],
},
"time_intervals": [],
},
"template_files": {},
}
`;
@@ -0,0 +1,144 @@
import { produce } from 'immer';
import { AlertManagerCortexConfig, MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../../types/amroutes';
import { addUniqueIdentifierToRoute } from '../../utils/amroutes';
import { addRouteAction, deleteRouteAction, routesReducer, updateRouteAction } from './notificationPolicyRoutes';
describe('routes', () => {
const defaultRoute: Route = {
receiver: 'ROOT',
group_by: ['grafana_folder'],
object_matchers: [],
routes: [
{
receiver: 'A',
object_matchers: [['team', MatcherOperator.equal, 'operations']],
routes: [
{
receiver: 'B1',
object_matchers: [['region', MatcherOperator.equal, 'europe']],
},
{
receiver: 'B2',
object_matchers: [['region', MatcherOperator.equal, 'nasa']],
},
],
},
{
receiver: 'C',
object_matchers: [['foo', MatcherOperator.equal, 'bar']],
},
],
group_wait: '10s',
group_interval: '1m',
};
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(defaultRoute);
const ROOT_identifier = rootRouteWithIdentifiers.id;
const A_identifier = rootRouteWithIdentifiers.routes![0].id;
const initialConfig: AlertManagerCortexConfig = {
alertmanager_config: {
time_intervals: [],
mute_time_intervals: [],
route: defaultRoute,
},
template_files: {},
};
it('Should add a new route with receiver E as a child of default route', () => {
const routeWithID = addUniqueIdentifierToRoute({
receiver: 'E',
});
const newFormRoute: Partial<FormAmRoute> = {
receiver: 'E',
id: routeWithID.id,
};
const action = addRouteAction({
alertmanager: 'alertmanager',
partialRoute: newFormRoute,
referenceRouteIdentifier: ROOT_identifier,
insertPosition: 'child',
});
const result = routesReducer(initialConfig, action);
expect(result.alertmanager_config.route?.routes).toHaveLength(
(initialConfig.alertmanager_config.route?.routes?.length ?? 0) + 1
);
// the last route if the root route should have receiver E
expect(result.alertmanager_config.route?.routes?.at(-1)).toMatchObject({
receiver: 'E',
});
// assert the rest of the configuration
expect(result).toMatchSnapshot();
});
it('Should not add a new route with receiver E as a child of route with receiver A, if data has been updated by another user', () => {
const routeWithID = addUniqueIdentifierToRoute({
receiver: 'E',
});
const newFormRoute: Partial<FormAmRoute> = {
receiver: 'E',
id: routeWithID.id,
};
const action = addRouteAction({
alertmanager: 'alertmanager',
partialRoute: newFormRoute,
referenceRouteIdentifier: A_identifier,
insertPosition: 'child',
});
const modifiedConfig = produce(initialConfig, (draft) => {
draft.alertmanager_config.route!.routes = [];
});
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(modifiedConfig.alertmanager_config.route!);
const modifiedConfigWithIdentifiers = produce(modifiedConfig, (draft) => {
draft.alertmanager_config.route = rootRouteWithIdentifiers;
});
expect(() => routesReducer(modifiedConfigWithIdentifiers, action)).toThrow();
});
it('Should update an existing route A with receiver B', () => {
const newRoute: Partial<FormAmRoute> = {
receiver: 'B',
id: A_identifier,
};
const action = updateRouteAction({
alertmanager: 'alertmanager',
update: newRoute,
});
expect(routesReducer(initialConfig, action)).toMatchSnapshot();
});
it('Should not update route if config has been updated by another user', () => {
const newRoute: Partial<FormAmRoute> = {
receiver: 'B',
id: A_identifier,
};
const action = updateRouteAction({
alertmanager: 'alertmanager',
update: newRoute,
});
const modifiedConfig = produce(initialConfig, (draft) => {
draft.alertmanager_config.route!.routes = [];
});
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(modifiedConfig.alertmanager_config.route!);
const modifiedConfigWithIdentifiers = produce(modifiedConfig, (draft) => {
draft.alertmanager_config.route = rootRouteWithIdentifiers;
});
expect(() => routesReducer(modifiedConfigWithIdentifiers, action)).toThrow();
});
it('Should delete route', () => {
const action = deleteRouteAction({
id: A_identifier,
});
expect(routesReducer(initialConfig, action)).toMatchSnapshot();
});
});
@@ -0,0 +1,82 @@
import { createAction, createReducer } from '@reduxjs/toolkit';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../../types/amroutes';
import { addUniqueIdentifierToRoute } from '../../utils/amroutes';
import {
InsertPosition,
addRouteToReferenceRoute,
cleanRouteIDs,
mergePartialAmRouteWithRouteTree,
omitRouteFromRouteTree,
} from '../../utils/routeTree';
export const updateRouteAction = createAction<{ update: Partial<FormAmRoute>; alertmanager: string }>('routes/update');
export const deleteRouteAction = createAction<{ id: string }>('routes/delete');
export const addRouteAction = createAction<{
alertmanager: string;
partialRoute: Partial<FormAmRoute>;
referenceRouteIdentifier: string;
insertPosition: InsertPosition;
}>('routes/add');
const initialState: AlertManagerCortexConfig = {
alertmanager_config: {},
template_files: {},
};
/**
* This reducer will manage action related to routes and make sure all operations on the alertmanager
* configuration happen immutably and only mutate what they need.
*/
export const routesReducer = createReducer(initialState, (builder) => {
builder
// update an existing route
.addCase(updateRouteAction, (draft, { payload }) => {
const { update, alertmanager } = payload;
const { alertmanager_config } = draft;
if (!alertmanager_config.route) {
return;
}
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(alertmanager_config.route);
const newRouteTree = mergePartialAmRouteWithRouteTree(alertmanager, update, rootRouteWithIdentifiers);
alertmanager_config.route = cleanRouteIDs(newRouteTree);
})
// delete a route
.addCase(deleteRouteAction, (draft, { payload }) => {
const { id } = payload;
const { alertmanager_config } = draft;
// if we don't even have a root route, we bail
if (!alertmanager_config.route) {
return;
}
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(alertmanager_config.route);
const updatedPolicyTree = omitRouteFromRouteTree(id, rootRouteWithIdentifiers);
draft.alertmanager_config.route = cleanRouteIDs(updatedPolicyTree);
})
// add a new route to given position
.addCase(addRouteAction, (draft, { payload }) => {
const { partialRoute, referenceRouteIdentifier, insertPosition, alertmanager } = payload;
const { alertmanager_config } = draft;
if (!alertmanager_config.route) {
return;
}
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(alertmanager_config.route);
const updatedPolicyTree = addRouteToReferenceRoute(
alertmanager,
partialRoute,
referenceRouteIdentifier,
rootRouteWithIdentifiers,
insertPosition
);
draft.alertmanager_config.route = cleanRouteIDs(updatedPolicyTree);
});
});
@@ -1,11 +1,14 @@
import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; import { uniqueId } from 'lodash';
import { MatcherOperator, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../types/amroutes'; import { FormAmRoute } from '../types/amroutes';
import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes'; import { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
const emptyAmRoute: Route = { const emptyAmRoute: RouteWithID = {
id: uniqueId(),
receiver: '', receiver: '',
group_by: [], group_by: [],
continue: false, continue: false,
@@ -20,8 +23,8 @@ const emptyAmRoute: Route = {
mute_time_intervals: [], mute_time_intervals: [],
}; };
const buildAmRoute = (override: Partial<Route> = {}): Route => { const buildAmRouteWithID = (override: Partial<RouteWithID> = {}): RouteWithID => {
return { ...emptyAmRoute, ...override }; return { ...emptyAmRoute, ...override, id: uniqueId() };
}; };
const buildFormAmRoute = (override: Partial<FormAmRoute> = {}): FormAmRoute => { const buildFormAmRoute = (override: Partial<FormAmRoute> = {}): FormAmRoute => {
@@ -138,7 +141,7 @@ describe('amRouteToFormAmRoute', () => {
describe('when called with empty group_by array', () => { describe('when called with empty group_by array', () => {
it('should set overrideGrouping true and groupBy empty', () => { it('should set overrideGrouping true and groupBy empty', () => {
// Arrange // Arrange
const amRoute = buildAmRoute({ group_by: [] }); const amRoute = buildAmRouteWithID({ group_by: [] });
// Act // Act
const formRoute = amRouteToFormAmRoute(amRoute); const formRoute = amRouteToFormAmRoute(amRoute);
@@ -156,7 +159,7 @@ describe('amRouteToFormAmRoute', () => {
${undefined} ${undefined}
`("when group_by is '$group_by', should set overrideGrouping false", ({ group_by }) => { `("when group_by is '$group_by', should set overrideGrouping false", ({ group_by }) => {
// Arrange // Arrange
const amRoute = buildAmRoute({ group_by: group_by }); const amRoute = buildAmRouteWithID({ group_by: group_by });
// Act // Act
const formRoute = amRouteToFormAmRoute(amRoute); const formRoute = amRouteToFormAmRoute(amRoute);
@@ -170,7 +173,7 @@ describe('amRouteToFormAmRoute', () => {
describe('when called with non-empty group_by', () => { describe('when called with non-empty group_by', () => {
it('Should set overrideGrouping true and groupBy', () => { it('Should set overrideGrouping true and groupBy', () => {
// Arrange // Arrange
const amRoute = buildAmRoute({ group_by: ['SHOULD BE SET'] }); const amRoute = buildAmRouteWithID({ group_by: ['SHOULD BE SET'] });
// Act // Act
const formRoute = amRouteToFormAmRoute(amRoute); const formRoute = amRouteToFormAmRoute(amRoute);
@@ -183,7 +186,7 @@ describe('amRouteToFormAmRoute', () => {
it('should unquote and unescape matchers values', () => { it('should unquote and unescape matchers values', () => {
// Arrange // Arrange
const amRoute = buildAmRoute({ const amRoute = buildAmRouteWithID({
matchers: ['foo=bar', 'foo="bar"', 'foo="bar"baz"', 'foo="bar\\\\baz"', 'foo="\\\\bar\\\\baz"\\\\"'], matchers: ['foo=bar', 'foo="bar"', 'foo="bar"baz"', 'foo="bar\\\\baz"', 'foo="\\\\bar\\\\baz"\\\\"'],
}); });
@@ -202,7 +205,7 @@ describe('amRouteToFormAmRoute', () => {
it('should unquote and unescape matcher names', () => { it('should unquote and unescape matcher names', () => {
// Arrange // Arrange
const amRoute = buildAmRoute({ const amRoute = buildAmRouteWithID({
matchers: ['"foo"=bar', '"foo with spaces"=bar', '"foo\\\\slash"=bar', '"foo"quote"=bar', '"fo\\\\o"="ba\\\\r"'], matchers: ['"foo"=bar', '"foo with spaces"=bar', '"foo\\\\slash"=bar', '"foo"quote"=bar', '"fo\\\\o"="ba\\\\r"'],
}); });
@@ -1,5 +1,3 @@
import { uniqueId } from 'lodash';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { MatcherOperator, ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { MatcherOperator, ObjectMatcher, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
@@ -9,7 +7,7 @@ import { MatcherFieldValue } from '../types/silence-form';
import { matcherToMatcherField } from './alertmanager'; import { matcherToMatcherField } from './alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
import { encodeMatcher, normalizeMatchers, parseMatcherToArray, unquoteWithUnescape } from './matchers'; import { encodeMatcher, normalizeMatchers, parseMatcherToArray, unquoteWithUnescape } from './matchers';
import { findExistingRoute } from './routeTree'; import { findExistingRoute, hashRoute } from './routeTree';
import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time'; import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time';
const matchersToArrayFieldMatchers = ( const matchersToArrayFieldMatchers = (
@@ -65,21 +63,25 @@ export const emptyRoute: FormAmRoute = {
}; };
// add unique identifiers to each route in the route tree, that way we can figure out what route we've edited / deleted // add unique identifiers to each route in the route tree, that way we can figure out what route we've edited / deleted
export function addUniqueIdentifierToRoute(route: Route): RouteWithID { // ⚠️ make sure this function uses _stable_ identifiers!
export function addUniqueIdentifierToRoute(route: Route, position = '0'): RouteWithID {
const routeHash = hashRoute(route);
const routes = route.routes ?? [];
return { return {
id: uniqueId('route-'), id: `${position}-${routeHash}`,
...route, ...route,
routes: (route.routes ?? []).map(addUniqueIdentifierToRoute), routes: routes.map((route, index) => addUniqueIdentifierToRoute(route, `${position}-${index}`)),
}; };
} }
//returns route, and a record mapping id to existing route // returns route, and a record mapping id to existing route
export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): FormAmRoute => { export const amRouteToFormAmRoute = (route: RouteWithID | undefined): FormAmRoute => {
if (!route) { if (!route) {
return emptyRoute; return emptyRoute;
} }
const id = 'id' in route ? route.id : uniqueId('route-'); const id = route.id;
if (Object.keys(route).length === 0) { if (Object.keys(route).length === 0) {
const formAmRoute = { ...emptyRoute, id }; const formAmRoute = { ...emptyRoute, id };
@@ -189,6 +189,7 @@ export function unquoteIfRequired(input: string) {
export const encodeMatcher = ({ name, operator, value }: MatcherFieldValue) => { export const encodeMatcher = ({ name, operator, value }: MatcherFieldValue) => {
const encodedLabelName = quoteWithEscapeIfRequired(name); const encodedLabelName = quoteWithEscapeIfRequired(name);
// @TODO why not use quoteWithEscapeIfRequired?
const encodedLabelValue = quoteWithEscape(value); const encodedLabelValue = quoteWithEscape(value);
return `${encodedLabelName}${operator}${encodedLabelValue}`; return `${encodedLabelName}${operator}${encodedLabelValue}`;
@@ -1,9 +1,16 @@
import { RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { MatcherOperator, ROUTES_META_SYMBOL, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../types/amroutes'; import { FormAmRoute } from '../types/amroutes';
import { GRAFANA_DATASOURCE_NAME } from './datasource'; import { GRAFANA_DATASOURCE_NAME } from './datasource';
import { addRouteToReferenceRoute, cleanRouteIDs, findRouteInTree, omitRouteFromRouteTree } from './routeTree'; import {
addRouteToReferenceRoute,
cleanRouteIDs,
findRouteInTree,
hashRoute,
omitRouteFromRouteTree,
stabilizeRoute,
} from './routeTree';
describe('findRouteInTree', () => { describe('findRouteInTree', () => {
it('should find the correct route', () => { it('should find the correct route', () => {
@@ -14,7 +21,7 @@ describe('findRouteInTree', () => {
routes: [{ id: 'route-1' }, needle, { id: 'route-3', routes: [{ id: 'route-4' }] }], routes: [{ id: 'route-1' }, needle, { id: 'route-3', routes: [{ id: 'route-4' }] }],
}; };
expect(findRouteInTree(root, { id: 'route-2' })).toStrictEqual([needle, root, 1]); expect(findRouteInTree(root, 'route-2')).toStrictEqual([needle, root, 1]);
}); });
it('should return undefined for unknown route', () => { it('should return undefined for unknown route', () => {
@@ -23,15 +30,15 @@ describe('findRouteInTree', () => {
routes: [{ id: 'route-1' }], routes: [{ id: 'route-1' }],
}; };
expect(findRouteInTree(root, { id: 'none' })).toStrictEqual([undefined, undefined, undefined]); expect(findRouteInTree(root, 'none')).toStrictEqual([undefined, undefined, undefined]);
}); });
}); });
describe('addRouteToReferenceRoute', () => { describe('addRouteToReferenceRoute', () => {
const targetRoute = { id: 'route-3' }; const targetRouteIdentifier = 'route-3';
const root: RouteWithID = { const root: RouteWithID = {
id: 'route-1', id: 'route-1',
routes: [{ id: 'route-2' }, targetRoute], routes: [{ id: 'route-2' }, { id: targetRouteIdentifier }],
}; };
const newRoute: Partial<FormAmRoute> = { const newRoute: Partial<FormAmRoute> = {
@@ -40,21 +47,25 @@ describe('addRouteToReferenceRoute', () => {
}; };
it('should be able to add above', () => { it('should be able to add above', () => {
expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'above')).toMatchSnapshot(); expect(
addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRouteIdentifier, root, 'above')
).toMatchSnapshot();
}); });
it('should be able to add below', () => { it('should be able to add below', () => {
expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'below')).toMatchSnapshot(); expect(
addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRouteIdentifier, root, 'below')
).toMatchSnapshot();
}); });
it('should be able to add as child', () => { it('should be able to add as child', () => {
expect(addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRoute, root, 'child')).toMatchSnapshot(); expect(
addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, targetRouteIdentifier, root, 'child')
).toMatchSnapshot();
}); });
it('should throw if target route does not exist', () => { it('should throw if target route does not exist', () => {
expect(() => expect(() => addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, 'unknown', root, 'child')).toThrow();
addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, { id: 'unknown' }, root, 'child')
).toThrow();
}); });
}); });
@@ -69,7 +80,7 @@ describe('omitRouteFromRouteTree', () => {
], ],
}; };
expect(omitRouteFromRouteTree({ id: 'route-4' }, tree)).toEqual({ expect(omitRouteFromRouteTree('route-4', tree)).toEqual({
id: 'route-1', id: 'route-1',
receiver: 'root', receiver: 'root',
routes: [ routes: [
@@ -85,7 +96,7 @@ describe('omitRouteFromRouteTree', () => {
}; };
expect(() => { expect(() => {
omitRouteFromRouteTree(tree, { id: 'route-1' }); omitRouteFromRouteTree(tree.id, { id: 'route-1' });
}).toThrow(); }).toThrow();
}); });
}); });
@@ -108,3 +119,57 @@ describe('cleanRouteIDs', () => {
expect(cleanRouteIDs({ receiver: 'test' })).toEqual({ receiver: 'test' }); expect(cleanRouteIDs({ receiver: 'test' })).toEqual({ receiver: 'test' });
}); });
}); });
describe('hashRoute and stabilizeRoute', () => {
it('should sort the correct route properties', () => {
const route: Route = {
receiver: 'foo',
group_by: ['g2', 'g1'],
object_matchers: [
['name2', MatcherOperator.equal, 'value2'],
['name1', MatcherOperator.equal, 'value1'],
],
routes: [{ receiver: 'b' }, { receiver: 'a' }],
match: {
b: 'b',
a: 'a',
},
};
const expected: Route = {
active_time_intervals: [],
continue: false,
group_interval: '',
group_wait: '',
group_by: ['g1', 'g2'],
match: {
a: 'a',
b: 'b',
},
match_re: {},
matchers: [],
mute_time_intervals: [],
object_matchers: [
['name1', MatcherOperator.equal, 'value1'],
['name2', MatcherOperator.equal, 'value2'],
],
provenance: '',
receiver: 'foo',
repeat_interval: '',
routes: [{ receiver: 'b' }, { receiver: 'a' }],
[ROUTES_META_SYMBOL]: {},
};
// the stabilizedRoute should match what we expect
expect(stabilizeRoute(route)).toEqual(expected);
// the hash of the route should be stable (so we assert is twice)
expect(hashRoute(route)).toBe('-1tfmmx');
expect(hashRoute(route)).toBe('-1tfmmx');
expect(hashRoute(expected)).toBe('-1tfmmx');
// the hash of the unstabilized route should be the same as the stabilized route
// because the hash function will stabilize the inputs
expect(hashRoute(route)).toBe(hashRoute(expected));
});
});
@@ -6,11 +6,16 @@ import { produce } from 'immer';
import { omit } from 'lodash'; import { omit } from 'lodash';
import { insertAfterImmutably, insertBeforeImmutably } from '@grafana/data/src/utils/arrayUtils'; import { insertAfterImmutably, insertBeforeImmutably } from '@grafana/data/src/utils/arrayUtils';
import { Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { ROUTES_META_SYMBOL, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
import {
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree,
} from '../openapi/routesApi.gen';
import { FormAmRoute } from '../types/amroutes'; import { FormAmRoute } from '../types/amroutes';
import { formAmRouteToAmRoute } from './amroutes'; import { formAmRouteToAmRoute } from './amroutes';
import { ERROR_NEWER_CONFIGURATION } from './k8s/errors';
// add a form submission to the route tree // add a form submission to the route tree
export const mergePartialAmRouteWithRouteTree = ( export const mergePartialAmRouteWithRouteTree = (
@@ -20,7 +25,12 @@ export const mergePartialAmRouteWithRouteTree = (
): Route => { ): Route => {
const existing = findExistingRoute(partialFormRoute.id ?? '', routeTree); const existing = findExistingRoute(partialFormRoute.id ?? '', routeTree);
if (!existing) { if (!existing) {
throw new Error(`No such route with ID '${partialFormRoute.id}'`); throw new Error(`No such route with ID '${partialFormRoute.id}'`, {
// this allows any error handling (when using stringifyErrorLike) to identify and translate this exception
// we do, however, make the assumption that this exception is the result of the policy tree having been updating by the user
// and this not being a programmer error.
cause: ERROR_NEWER_CONFIGURATION,
});
} }
function findAndReplace(currentRoute: RouteWithID): Route { function findAndReplace(currentRoute: RouteWithID): Route {
@@ -45,8 +55,8 @@ export const mergePartialAmRouteWithRouteTree = (
// remove a route from the policy tree, returns a new tree // remove a route from the policy tree, returns a new tree
// make sure to omit the "id" because Prometheus / Loki / Mimir will reject the payload // make sure to omit the "id" because Prometheus / Loki / Mimir will reject the payload
export const omitRouteFromRouteTree = (findRoute: RouteWithID, routeTree: RouteWithID): RouteWithID => { export const omitRouteFromRouteTree = (id: string, routeTree: RouteWithID): RouteWithID => {
if (findRoute.id === routeTree.id) { if (id === routeTree.id) {
throw new Error('You cant remove the root policy'); throw new Error('You cant remove the root policy');
} }
@@ -54,7 +64,7 @@ export const omitRouteFromRouteTree = (findRoute: RouteWithID, routeTree: RouteW
return { return {
...currentRoute, ...currentRoute,
routes: currentRoute.routes?.reduce((acc: RouteWithID[] = [], route) => { routes: currentRoute.routes?.reduce((acc: RouteWithID[] = [], route) => {
if (route.id === findRoute.id) { if (route.id === id) {
return acc; return acc;
} }
@@ -73,17 +83,19 @@ export type InsertPosition = 'above' | 'below' | 'child';
export const addRouteToReferenceRoute = ( export const addRouteToReferenceRoute = (
alertManagerSourceName: string, alertManagerSourceName: string,
partialFormRoute: Partial<FormAmRoute>, partialFormRoute: Partial<FormAmRoute>,
referenceRoute: RouteWithID, referenceRouteIdentifier: string,
routeTree: RouteWithID, routeTree: RouteWithID,
position: InsertPosition position: InsertPosition
): RouteWithID => { ): RouteWithID => {
const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree); const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree);
return produce(routeTree, (draftTree) => { return produce(routeTree, (draftTree) => {
const [routeInTree, parentRoute, positionInParent] = findRouteInTree(draftTree, referenceRoute); const [routeInTree, parentRoute, positionInParent] = findRouteInTree(draftTree, referenceRouteIdentifier);
if (routeInTree === undefined || parentRoute === undefined || positionInParent === undefined) { if (routeInTree === undefined || parentRoute === undefined || positionInParent === undefined) {
throw new Error(`could not find reference route "${referenceRoute.id}" in tree`); throw new Error(`could not find reference route "${referenceRouteIdentifier}" in tree`, {
cause: ERROR_NEWER_CONFIGURATION,
});
} }
// if user wants to insert new child policy, append to the bottom of children // if user wants to insert new child policy, append to the bottom of children
@@ -111,7 +123,7 @@ type RouteMatch = Route | undefined;
export function findRouteInTree( export function findRouteInTree(
routeTree: RouteWithID, routeTree: RouteWithID,
referenceRoute: RouteWithID referenceRouteIdentifier: string
): [matchingRoute: RouteMatch, parentRoute: RouteMatch, positionInParent: number | undefined] { ): [matchingRoute: RouteMatch, parentRoute: RouteMatch, positionInParent: number | undefined] {
let matchingRoute: RouteMatch; let matchingRoute: RouteMatch;
let matchingRouteParent: RouteMatch; let matchingRouteParent: RouteMatch;
@@ -123,7 +135,7 @@ export function findRouteInTree(
return; return;
} }
if (currentRoute.id === referenceRoute.id) { if (currentRoute.id === referenceRouteIdentifier) {
matchingRoute = currentRoute; matchingRoute = currentRoute;
matchingRouteParent = parentRoute; matchingRouteParent = parentRoute;
matchingRoutePositionInParent = index; matchingRoutePositionInParent = index;
@@ -139,7 +151,9 @@ export function findRouteInTree(
return [matchingRoute, matchingRouteParent, matchingRoutePositionInParent]; return [matchingRoute, matchingRouteParent, matchingRoutePositionInParent];
} }
export function cleanRouteIDs(route: Route | RouteWithID): Route { export function cleanRouteIDs<
T extends RouteWithID | Route | ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
>(route: T): Omit<T, 'id'> {
return omit( return omit(
{ {
...route, ...route,
@@ -149,6 +163,70 @@ export function cleanRouteIDs(route: Route | RouteWithID): Route {
); );
} }
// remove IDs from the Kubernetes routes
export function cleanKubernetesRouteIDs(
routingTree: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree
): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree {
return produce(routingTree, (draft) => {
draft.spec.routes = draft.spec.routes.map(cleanRouteIDs);
});
}
export function findExistingRoute(id: string, routeTree: RouteWithID): RouteWithID | undefined { export function findExistingRoute(id: string, routeTree: RouteWithID): RouteWithID | undefined {
return routeTree.id === id ? routeTree : routeTree.routes?.find((route) => findExistingRoute(id, route)); return routeTree.id === id ? routeTree : routeTree.routes?.find((route) => findExistingRoute(id, route));
} }
/**
* This function converts an object into a unique hash by sorting the keys and applying a simple integer hash
*/
export function hashRoute(route: Route): string {
const jsonString = JSON.stringify(stabilizeRoute(route));
// Simple hash function - convert to a number-based hash
let hash = 0;
for (let i = 0; i < jsonString.length; i++) {
const char = jsonString.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString(36);
}
/**
* This function will sort the route's values and set the keys in a deterministic order
*/
export function stabilizeRoute(route: Route): Required<Route> {
const result: Required<Route> = {
receiver: route.receiver ?? '',
group_by: route.group_by ? [...route.group_by].sort() : [],
continue: route.continue ?? false,
object_matchers: route.object_matchers ? [...route.object_matchers].sort() : [],
matchers: route.matchers ? [...route.matchers].sort() : [],
match: route.match ? sortRecord(route.match) : {},
match_re: route.match_re ? sortRecord(route.match_re) : {},
group_wait: route.group_wait ?? '',
group_interval: route.group_interval ?? '',
repeat_interval: route.repeat_interval ?? '',
routes: route.routes ?? [], // routes are not sorted as if the order of the routes has changed, the hash should be different
mute_time_intervals: route.mute_time_intervals ? [...route.mute_time_intervals].sort() : [],
active_time_intervals: route.active_time_intervals ? [...route.active_time_intervals].sort() : [],
provenance: route.provenance ?? '',
[ROUTES_META_SYMBOL]: {},
};
return result;
}
// function to sort the keys of a record
function sortRecord(record: Record<string, string>): Record<string, string> {
const sorted: Record<string, string> = {};
Object.keys(record)
.sort()
.forEach((key) => {
sorted[key] = record[key];
});
return sorted;
}
@@ -245,7 +245,7 @@ export function stringifyIdentifier(identifier: RuleIdentifier): string {
.join('$'); .join('$');
} }
function hash(value: string): number { export function hash(value: string): number {
let hash = 0; let hash = 0;
if (value.length === 0) { if (value.length === 0) {
return hash; return hash;
@@ -2,6 +2,8 @@
import { DataSourceJsonData, WithAccessControlMetadata } from '@grafana/data'; import { DataSourceJsonData, WithAccessControlMetadata } from '@grafana/data';
import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/receiversApi.gen'; import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/receiversApi.gen';
export const ROUTES_META_SYMBOL = Symbol('routes_metadata');
export type AlertManagerCortexConfig = { export type AlertManagerCortexConfig = {
template_files: Record<string, string>; template_files: Record<string, string>;
alertmanager_config: AlertmanagerConfig; alertmanager_config: AlertmanagerConfig;
@@ -133,7 +135,8 @@ export type Route = {
active_time_intervals?: string[]; active_time_intervals?: string[];
/** only the root policy might have a provenance field defined */ /** only the root policy might have a provenance field defined */
provenance?: string; provenance?: string;
_metadata?: { /** this is used to add additional metadata to the routes without interfering with original route definition (symbols aren't iterable) */
[ROUTES_META_SYMBOL]?: {
provisioned?: boolean; provisioned?: boolean;
resourceVersion?: string; resourceVersion?: string;
name?: string; name?: string;