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 19:06:50 +01:00
committed by GitHub
parent 59184628e1
commit 798b32138d
17 changed files with 1039 additions and 207 deletions

View File

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

View File

@ -7,7 +7,6 @@ import { AlertState, AlertmanagerGroup, ObjectMatcher, RouteWithID } from 'app/p
import { FormAmRoute } from '../../types/amroutes';
import { MatcherFormatter } from '../../utils/matchers';
import { stringifyErrorLike } from '../../utils/misc';
import { InsertPosition } from '../../utils/routeTree';
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 AddModalHook<T = undefined> = [JSX.Element, (item: T, position: InsertPosition) => void, () => void];
type EditModalHook = [JSX.Element, (item: RouteWithID, isDefaultRoute?: boolean) => void, () => void];
const useAddPolicyModal = (
handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => void,
handleAdd: (route: Partial<FormAmRoute>, referenceRoute: RouteWithID, position: InsertPosition) => Promise<void>,
loading: boolean
): AddModalHook<RouteWithID> => {
const [showModal, setShowModal] = useState(false);
@ -32,6 +30,7 @@ const useAddPolicyModal = (
const handleDismiss = useCallback(() => {
setReferenceRoute(undefined);
setInsertPosition(undefined);
setError(undefined);
setShowModal(false);
}, []);
@ -41,6 +40,8 @@ const useAddPolicyModal = (
setShowModal(true);
}, []);
const [error, setError] = useState<Error | undefined>(undefined);
const modalElement = useMemo(
() =>
loading ? (
@ -53,13 +54,14 @@ const useAddPolicyModal = (
closeOnEscape={true}
title="Add notification policy"
>
{error && <NotificationPoliciesErrorAlert error={error} />}
<AmRoutesExpandedForm
defaults={{
groupBy: referenceRoute?.group_by,
}}
onSubmit={(newRoute) => {
if (referenceRoute && insertPosition) {
handleAdd(newRoute, referenceRoute, insertPosition);
handleAdd(newRoute, referenceRoute, insertPosition).catch(setError);
}
}}
actionButtons={
@ -75,7 +77,7 @@ const useAddPolicyModal = (
/>
</Modal>
),
[handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal]
[error, handleAdd, handleDismiss, insertPosition, loading, referenceRoute, showModal, setError]
);
return [modalElement, handleShow, handleDismiss];
@ -83,17 +85,19 @@ const useAddPolicyModal = (
const useEditPolicyModal = (
alertManagerSourceName: string,
handleSave: (route: Partial<FormAmRoute>) => void,
loading: boolean,
error?: Error
handleUpdate: (route: Partial<FormAmRoute>) => Promise<void>,
loading: boolean
): EditModalHook => {
const [showModal, setShowModal] = useState(false);
const [isDefaultPolicy, setIsDefaultPolicy] = useState(false);
const [route, setRoute] = useState<RouteWithID>();
const [error, setError] = useState<Error | undefined>(undefined);
const handleDismiss = useCallback(() => {
setRoute(undefined);
setShowModal(false);
setError(undefined);
}, []);
const handleShow = useCallback((route: RouteWithID, isDefaultPolicy?: boolean) => {
@ -114,13 +118,13 @@ const useEditPolicyModal = (
closeOnEscape={true}
title="Edit notification policy"
>
{error && <NotificationPoliciesErrorAlert error={stringifyErrorLike(error)} />}
{error && <NotificationPoliciesErrorAlert error={error} />}
{isDefaultPolicy && route && (
<AmRootRouteForm
// TODO *sigh* this alertmanagersourcename should come from context or something
// passing it down all the way here is a code smell
alertManagerSourceName={alertManagerSourceName}
onSubmit={handleSave}
onSubmit={(values) => handleUpdate(values).catch(setError)}
route={route}
actionButtons={
<Modal.ButtonRow>
@ -137,7 +141,7 @@ const useEditPolicyModal = (
{!isDefaultPolicy && (
<AmRoutesExpandedForm
route={route}
onSubmit={handleSave}
onSubmit={(values) => handleUpdate(values).catch(setError)}
actionButtons={
<Modal.ButtonRow>
<Button type="button" variant="secondary" onClick={handleDismiss} fill="outline">
@ -152,19 +156,24 @@ const useEditPolicyModal = (
)}
</Modal>
),
[alertManagerSourceName, error, handleDismiss, handleSave, isDefaultPolicy, loading, route, showModal]
[loading, showModal, handleDismiss, error, isDefaultPolicy, route, alertManagerSourceName, handleUpdate, setError]
);
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 [route, setRoute] = useState<RouteWithID>();
const [error, setError] = useState<Error | undefined>(undefined);
const handleDismiss = useCallback(() => {
setRoute(undefined);
setShowModal(false);
setError(undefined);
}, [setRoute]);
const handleShow = useCallback((route: RouteWithID) => {
@ -172,12 +181,6 @@ const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loadin
setShowModal(true);
}, []);
const handleSubmit = useCallback(() => {
if (route) {
handleDelete(route);
}
}, [handleDelete, route]);
const modalElement = useMemo(
() =>
loading ? (
@ -190,13 +193,13 @@ const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loadin
closeOnEscape={true}
title="Delete notification policy"
>
{error && <NotificationPoliciesErrorAlert error={error} />}
<Trans i18nKey="alerting.policies.delete.warning-1">
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>
<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>
</Button>
<Button type="button" variant="secondary" onClick={handleDismiss}>
@ -205,7 +208,7 @@ const useDeletePolicyModal = (handleDelete: (route: RouteWithID) => void, loadin
</Modal.ButtonRow>
</Modal>
),
[handleDismiss, handleSubmit, loading, showModal]
[handleDismiss, loading, showModal, error, route, handleDelete]
);
return [modalElement, handleShow, handleDismiss];

View File

@ -8,28 +8,27 @@ import { useContactPointsWithStatus } from 'app/features/alerting/unified/compon
import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
import { FormAmRoute } from 'app/features/alerting/unified/types/amroutes';
import { addUniqueIdentifierToRoute } from 'app/features/alerting/unified/utils/amroutes';
import { ERROR_NEWER_CONFIGURATION } from 'app/features/alerting/unified/utils/k8s/errors';
import { isErrorMatchingCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
import { getErrorCode, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
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 { ERROR_NEWER_CONFIGURATION } from '../../utils/k8s/errors';
import { alertmanagerApi } from './../../api/alertmanagerApi';
import { useGetContactPointsState } from './../../api/receiversApi';
import { isLoading as isPending, useAsync } from './../../hooks/useAsync';
import { useRouteGroupsMatcher } from './../../useRouteGroupsMatcher';
import {
InsertPosition,
addRouteToReferenceRoute,
cleanRouteIDs,
mergePartialAmRouteWithRouteTree,
omitRouteFromRouteTree,
} from './../../utils/routeTree';
import { InsertPosition } from './../../utils/routeTree';
import { NotificationPoliciesFilter, findRoutesByMatchers, findRoutesMatchingPredicate } from './Filters';
import { useAddPolicyModal, useAlertGroupsModal, useDeletePolicyModal, useEditPolicyModal } from './Modals';
import { Policy } from './Policy';
import { useNotificationPolicyRoute, useUpdateNotificationPolicyRoute } from './useNotificationPolicyRoute';
import {
useAddNotificationPolicy,
useDeleteNotificationPolicy,
useNotificationPolicyRoute,
useUpdateExistingNotificationPolicy,
} from './useNotificationPolicyRoute';
export const NotificationPoliciesList = () => {
const appNotification = useAppNotification();
@ -53,7 +52,7 @@ export const NotificationPoliciesList = () => {
const {
currentData,
isLoading,
error: resultError,
error: fetchPoliciesError,
refetch: refetchNotificationPolicyRoute,
} = useNotificationPolicyRoute({ alertmanager: selectedAlertmanager ?? '' });
@ -62,7 +61,22 @@ export const NotificationPoliciesList = () => {
// TODO in the future: Generalise the component to support any number of "root" policies
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(
{ amSourceName: selectedAlertmanager ?? '' },
@ -112,56 +126,38 @@ export const NotificationPoliciesList = () => {
const refetchPolicies = () => {
refetchNotificationPolicyRoute();
updateRouteTree.reset();
updateExistingNotificationPolicy.reset();
deleteNotificationPolicy.reset();
addNotificationPolicy.reset();
};
function handleSave(partialRoute: Partial<FormAmRoute>) {
if (!rootRoute) {
return;
}
const newRouteTree = mergePartialAmRouteWithRouteTree(selectedAlertmanager ?? '', partialRoute, rootRoute);
updateRouteTree.execute(newRouteTree);
async function handleUpdate(partialRoute: Partial<FormAmRoute>) {
await updateExistingNotificationPolicy.execute(partialRoute);
handleActionResult({ error: updateExistingNotificationPolicyState.error });
}
function handleDelete(route: RouteWithID) {
if (!rootRoute) {
return;
}
const newRouteTree = omitRouteFromRouteTree(route, rootRoute);
updateRouteTree.execute(newRouteTree);
async function handleDelete(route: RouteWithID) {
await deleteNotificationPolicy.execute(route.id);
handleActionResult({ error: deleteNotificationPolicyState.error });
}
function handleAdd(partialRoute: Partial<FormAmRoute>, referenceRoute: RouteWithID, insertPosition: InsertPosition) {
if (!rootRoute) {
return;
}
const newRouteTree = addRouteToReferenceRoute(
selectedAlertmanager ?? '',
async function handleAdd(
partialRoute: Partial<FormAmRoute>,
referenceRoute: RouteWithID,
insertPosition: InsertPosition
) {
await addNotificationPolicy.execute({
partialRoute,
referenceRoute,
rootRoute,
insertPosition
);
updateRouteTree.execute(newRouteTree);
referenceRouteIdentifier: referenceRoute.id,
insertPosition,
});
handleActionResult({ error: addNotificationPolicyState.error });
}
// this function will make the HTTP request and tracks the state of the request
const [updateRouteTree, updateRouteTreeState] = useAsync(async (routeTree: Route | RouteWithID) => {
if (!rootRoute) {
return;
function handleActionResult({ error }: { error?: Error }) {
if (!error) {
appNotification.success('Updated notification policies');
}
// 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) {
refetchAlertGroups();
}
@ -170,20 +166,20 @@ export const NotificationPoliciesList = () => {
closeEditModal();
closeAddModal();
closeDeleteModal();
}
return newTree;
});
const updatingTree = isPending(updateRouteTreeState);
const updateError = updateRouteTreeState.error;
const updatingTree = anyOfRequestState(
updateExistingNotificationPolicyState,
deleteNotificationPolicyState,
addNotificationPolicyState
).loading;
// edit, add, delete modals
const [addModal, openAddModal, closeAddModal] = useAddPolicyModal(handleAdd, updatingTree);
const [editModal, openEditModal, closeEditModal] = useEditPolicyModal(
selectedAlertmanager ?? '',
handleSave,
updatingTree,
updateError
handleUpdate,
updatingTree
);
const [deleteModal, openDeleteModal, closeDeleteModal] = useDeletePolicyModal(handleDelete, updatingTree);
const [alertInstancesModal, showAlertGroupsModal] = useAlertGroupsModal(selectedAlertmanager ?? '');
@ -192,18 +188,23 @@ export const NotificationPoliciesList = () => {
return null;
}
const hasPoliciesData = rootRoute && !resultError && !isLoading;
const hasPoliciesError = !!resultError && !isLoading;
const hasPoliciesData = rootRoute && !fetchPoliciesError && !isLoading;
const hasPoliciesError = Boolean(fetchPoliciesError) && !isLoading;
const hasConflictError = [
addNotificationPolicyState,
updateExistingNotificationPolicyState,
deleteNotificationPolicyState,
].some((state) => isError(state) && getErrorCode(state.error) === ERROR_NEWER_CONFIGURATION);
return (
<>
{hasPoliciesError && (
<Alert severity="error" title="Error loading Alertmanager config">
{stringifyErrorLike(resultError) || 'Unknown error.'}
{stringifyErrorLike(fetchPoliciesError) || 'Unknown error.'}
</Alert>
)}
{/* show when there is an update error */}
{isErrorMatchingCode(updateError, ERROR_NEWER_CONFIGURATION) && (
{hasConflictError && (
<Alert severity="info" title="Notification policies have changed">
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Trans i18nKey="alerting.policies.update-errors.conflict">
@ -230,7 +231,7 @@ export const NotificationPoliciesList = () => {
currentRoute={rootRoute}
contactPointsState={contactPointsState.receivers}
readOnly={!hasConfigurationAPI}
provisioned={rootRoute._metadata?.provisioned}
provisioned={rootRoute[ROUTES_META_SYMBOL]?.provisioned}
alertManagerSourceName={selectedAlertmanager}
onAddPolicy={openAddModal}
onEditPolicy={openEditModal}

View File

@ -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,
},
],
},
}
`;

View File

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

View File

@ -1,27 +1,45 @@
import { pick } from 'lodash';
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 { 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 { useAsync } from '../../hooks/useAsync';
import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig';
import {
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults,
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree,
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTreeSpec,
generatedRoutesApi as routingTreeApi,
} 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 { ERROR_NEWER_CONFIGURATION } from '../../utils/k8s/errors';
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 { useListNamespacedRoutingTreeQuery, useReplaceNamespacedRoutingTreeMutation } = routingTreeApi;
const {
useUpdateAlertmanagerConfigurationMutation,
useLazyGetAlertmanagerConfigurationQuery,
useGetAlertmanagerConfigurationQuery,
} = alertmanagerApi;
useListNamespacedRoutingTreeQuery,
useReplaceNamespacedRoutingTreeMutation,
useLazyListNamespacedRoutingTreeQuery,
} = routingTreeApi;
const { useGetAlertmanagerConfigurationQuery } = alertmanagerApi;
export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArgs, { skip }: Skippable = {}) => {
const k8sApiSupported = shouldUseK8sApi(alertmanager);
@ -61,73 +79,149 @@ export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArg
const parseAmConfigRoute = memoize((route: Route): Route => {
return {
...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) {
const [getAlertmanagerConfiguration] = useLazyGetAlertmanagerConfigurationQuery();
const [updateAlertmanagerConfiguration] = useUpdateAlertmanagerConfigurationMutation();
export function useUpdateExistingNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) {
const k8sApiSupported = shouldUseK8sApi(alertmanager);
const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation();
const [produceNewAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
const [listNamespacedRoutingTree] = useLazyListNamespacedRoutingTreeQuery();
const k8sApiSupported = shouldUseK8sApi(selectedAlertmanager);
async function updateUsingK8sApi({ newRoute }: { newRoute: Route }) {
const updateUsingK8sApi = useAsync(async (update: Partial<FormAmRoute>) => {
const namespace = getK8sNamespace();
const { routes, _metadata, ...defaults } = newRoute;
// Remove provenance so we don't send it to API
// Convert Route to K8s compatible format
const k8sRoute: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTreeSpec = {
defaults: {
...defaults,
// TODO: Fix types in k8s API? Fix our types to not allow empty receiver? TBC
receiver: defaults.receiver || '',
},
routes: newRoute.routes?.map(routeToK8sSubRoute) || [],
};
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 = mergePartialAmRouteWithRouteTree(alertmanager, update, rootRouteWithIdentifiers);
// Create the K8s route object
const routeObject: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree = {
spec: k8sRoute,
metadata: { name: ROOT_ROUTE_NAME, resourceVersion: _metadata?.resourceVersion },
};
const routeObject = createKubernetesRoutingTreeSpec(newRouteTree);
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({
name: ROOT_ROUTE_NAME,
namespace,
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: routeObject,
}).unwrap();
}
});
async function updateUsingConfigFileApi({ newRoute, oldRoute }: { newRoute: Route; oldRoute: Route }) {
const { _metadata, ...oldRouteStripped } = oldRoute;
const { _metadata: newMetadata, ...newRouteStripped } = newRoute;
const lastConfig = await getAlertmanagerConfiguration(selectedAlertmanager).unwrap();
const latestRouteFromConfig = lastConfig.alertmanager_config.route;
const deleteFromAlertmanagerConfiguration = useAsync(async (id: string) => {
const action = deleteRouteAction({ id });
return produceNewAlertmanagerConfiguration(action);
});
const configChangedInMeantime = JSON.stringify(oldRouteStripped) !== JSON.stringify(latestRouteFromConfig);
return k8sApiSupported ? deleteFromK8sApi : deleteFromAlertmanagerConfiguration;
}
if (configChangedInMeantime) {
throw new Error('configuration modification conflict', { cause: ERROR_NEWER_CONFIGURATION });
export function useAddNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) {
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 = {
...lastConfig,
alertmanager_config: {
...lastConfig.alertmanager_config,
route: newRouteStripped,
},
};
const addToAlertmanagerConfiguration = useAsync(
async ({
partialRoute,
referenceRouteIdentifier,
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 updateAlertmanagerConfiguration({
selectedAlertmanager,
config: newConfig,
}).unwrap();
}
return k8sApiSupported ? updateUsingK8sApi : updateUsingConfigFileApi;
return k8sApiSupported ? addToK8sApi : addToAlertmanagerConfiguration;
}
function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree[]): Route[] {
@ -135,7 +229,7 @@ function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotific
return {
...route.spec.defaults,
routes: route.spec.routes?.map(k8sSubRouteToRoute),
_metadata: {
[ROUTES_META_SYMBOL]: {
provisioned: isK8sEntityProvisioned(route),
resourceVersion: route.metadata.resourceVersion,
name: route.metadata.name,
@ -149,7 +243,7 @@ function isValidMatcherOperator(type: string): type is MatcherOperator {
return Object.values<string>(MatcherOperator).includes(type);
}
function k8sSubRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route): Route {
export function k8sSubRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route): Route {
return {
...route,
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;
return {
...rest,
@ -176,3 +270,34 @@ function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApisAlertin
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,
},
};
}

View File

@ -5,6 +5,7 @@ import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/ty
import { alertmanagerApi } from '../api/alertmanagerApi';
import { muteTimingsReducer } from '../reducers/alertmanager/muteTimings';
import { routesReducer } from '../reducers/alertmanager/notificationPolicyRoutes';
import { notificationTemplatesReducer } from '../reducers/alertmanager/notificationTemplates';
import { receiversReducer } from '../reducers/alertmanager/receivers';
import { useAlertmanager } from '../state/AlertmanagerContext';
@ -31,7 +32,8 @@ const configurationReducer = reduceReducers(
initialAlertmanagerConfiguration,
muteTimingsReducer,
receiversReducer,
notificationTemplatesReducer
notificationTemplatesReducer,
routesReducer
);
/**

View File

@ -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": {},
}
`;

View File

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

View File

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

View File

@ -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 { amRouteToFormAmRoute, emptyRoute, formAmRouteToAmRoute } from './amroutes';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
const emptyAmRoute: Route = {
const emptyAmRoute: RouteWithID = {
id: uniqueId(),
receiver: '',
group_by: [],
continue: false,
@ -20,8 +23,8 @@ const emptyAmRoute: Route = {
mute_time_intervals: [],
};
const buildAmRoute = (override: Partial<Route> = {}): Route => {
return { ...emptyAmRoute, ...override };
const buildAmRouteWithID = (override: Partial<RouteWithID> = {}): RouteWithID => {
return { ...emptyAmRoute, ...override, id: uniqueId() };
};
const buildFormAmRoute = (override: Partial<FormAmRoute> = {}): FormAmRoute => {
@ -138,7 +141,7 @@ describe('amRouteToFormAmRoute', () => {
describe('when called with empty group_by array', () => {
it('should set overrideGrouping true and groupBy empty', () => {
// Arrange
const amRoute = buildAmRoute({ group_by: [] });
const amRoute = buildAmRouteWithID({ group_by: [] });
// Act
const formRoute = amRouteToFormAmRoute(amRoute);
@ -156,7 +159,7 @@ describe('amRouteToFormAmRoute', () => {
${undefined}
`("when group_by is '$group_by', should set overrideGrouping false", ({ group_by }) => {
// Arrange
const amRoute = buildAmRoute({ group_by: group_by });
const amRoute = buildAmRouteWithID({ group_by: group_by });
// Act
const formRoute = amRouteToFormAmRoute(amRoute);
@ -170,7 +173,7 @@ describe('amRouteToFormAmRoute', () => {
describe('when called with non-empty group_by', () => {
it('Should set overrideGrouping true and groupBy', () => {
// Arrange
const amRoute = buildAmRoute({ group_by: ['SHOULD BE SET'] });
const amRoute = buildAmRouteWithID({ group_by: ['SHOULD BE SET'] });
// Act
const formRoute = amRouteToFormAmRoute(amRoute);
@ -183,7 +186,7 @@ describe('amRouteToFormAmRoute', () => {
it('should unquote and unescape matchers values', () => {
// Arrange
const amRoute = buildAmRoute({
const amRoute = buildAmRouteWithID({
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', () => {
// Arrange
const amRoute = buildAmRoute({
const amRoute = buildAmRouteWithID({
matchers: ['"foo"=bar', '"foo with spaces"=bar', '"foo\\\\slash"=bar', '"foo"quote"=bar', '"fo\\\\o"="ba\\\\r"'],
});

View File

@ -1,5 +1,3 @@
import { uniqueId } from 'lodash';
import { SelectableValue } from '@grafana/data';
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 { GRAFANA_RULES_SOURCE_NAME } from './datasource';
import { encodeMatcher, normalizeMatchers, parseMatcherToArray, unquoteWithUnescape } from './matchers';
import { findExistingRoute } from './routeTree';
import { findExistingRoute, hashRoute } from './routeTree';
import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time';
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
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 {
id: uniqueId('route-'),
id: `${position}-${routeHash}`,
...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
export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): FormAmRoute => {
// returns route, and a record mapping id to existing route
export const amRouteToFormAmRoute = (route: RouteWithID | undefined): FormAmRoute => {
if (!route) {
return emptyRoute;
}
const id = 'id' in route ? route.id : uniqueId('route-');
const id = route.id;
if (Object.keys(route).length === 0) {
const formAmRoute = { ...emptyRoute, id };

View File

@ -189,6 +189,7 @@ export function unquoteIfRequired(input: string) {
export const encodeMatcher = ({ name, operator, value }: MatcherFieldValue) => {
const encodedLabelName = quoteWithEscapeIfRequired(name);
// @TODO why not use quoteWithEscapeIfRequired?
const encodedLabelValue = quoteWithEscape(value);
return `${encodedLabelName}${operator}${encodedLabelValue}`;

View File

@ -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 { GRAFANA_DATASOURCE_NAME } from './datasource';
import { addRouteToReferenceRoute, cleanRouteIDs, findRouteInTree, omitRouteFromRouteTree } from './routeTree';
import {
addRouteToReferenceRoute,
cleanRouteIDs,
findRouteInTree,
hashRoute,
omitRouteFromRouteTree,
stabilizeRoute,
} from './routeTree';
describe('findRouteInTree', () => {
it('should find the correct route', () => {
@ -14,7 +21,7 @@ describe('findRouteInTree', () => {
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', () => {
@ -23,15 +30,15 @@ describe('findRouteInTree', () => {
routes: [{ id: 'route-1' }],
};
expect(findRouteInTree(root, { id: 'none' })).toStrictEqual([undefined, undefined, undefined]);
expect(findRouteInTree(root, 'none')).toStrictEqual([undefined, undefined, undefined]);
});
});
describe('addRouteToReferenceRoute', () => {
const targetRoute = { id: 'route-3' };
const targetRouteIdentifier = 'route-3';
const root: RouteWithID = {
id: 'route-1',
routes: [{ id: 'route-2' }, targetRoute],
routes: [{ id: 'route-2' }, { id: targetRouteIdentifier }],
};
const newRoute: Partial<FormAmRoute> = {
@ -40,21 +47,25 @@ describe('addRouteToReferenceRoute', () => {
};
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', () => {
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', () => {
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', () => {
expect(() =>
addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, { id: 'unknown' }, root, 'child')
).toThrow();
expect(() => addRouteToReferenceRoute(GRAFANA_DATASOURCE_NAME, newRoute, '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',
receiver: 'root',
routes: [
@ -85,7 +96,7 @@ describe('omitRouteFromRouteTree', () => {
};
expect(() => {
omitRouteFromRouteTree(tree, { id: 'route-1' });
omitRouteFromRouteTree(tree.id, { id: 'route-1' });
}).toThrow();
});
});
@ -108,3 +119,57 @@ describe('cleanRouteIDs', () => {
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));
});
});

View File

@ -6,11 +6,16 @@ import { produce } from 'immer';
import { omit } from 'lodash';
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 { formAmRouteToAmRoute } from './amroutes';
import { ERROR_NEWER_CONFIGURATION } from './k8s/errors';
// add a form submission to the route tree
export const mergePartialAmRouteWithRouteTree = (
@ -20,7 +25,12 @@ export const mergePartialAmRouteWithRouteTree = (
): Route => {
const existing = findExistingRoute(partialFormRoute.id ?? '', routeTree);
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 {
@ -45,8 +55,8 @@ export const mergePartialAmRouteWithRouteTree = (
// 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
export const omitRouteFromRouteTree = (findRoute: RouteWithID, routeTree: RouteWithID): RouteWithID => {
if (findRoute.id === routeTree.id) {
export const omitRouteFromRouteTree = (id: string, routeTree: RouteWithID): RouteWithID => {
if (id === routeTree.id) {
throw new Error('You cant remove the root policy');
}
@ -54,7 +64,7 @@ export const omitRouteFromRouteTree = (findRoute: RouteWithID, routeTree: RouteW
return {
...currentRoute,
routes: currentRoute.routes?.reduce((acc: RouteWithID[] = [], route) => {
if (route.id === findRoute.id) {
if (route.id === id) {
return acc;
}
@ -73,17 +83,19 @@ export type InsertPosition = 'above' | 'below' | 'child';
export const addRouteToReferenceRoute = (
alertManagerSourceName: string,
partialFormRoute: Partial<FormAmRoute>,
referenceRoute: RouteWithID,
referenceRouteIdentifier: string,
routeTree: RouteWithID,
position: InsertPosition
): RouteWithID => {
const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree);
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) {
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
@ -111,7 +123,7 @@ type RouteMatch = Route | undefined;
export function findRouteInTree(
routeTree: RouteWithID,
referenceRoute: RouteWithID
referenceRouteIdentifier: string
): [matchingRoute: RouteMatch, parentRoute: RouteMatch, positionInParent: number | undefined] {
let matchingRoute: RouteMatch;
let matchingRouteParent: RouteMatch;
@ -123,7 +135,7 @@ export function findRouteInTree(
return;
}
if (currentRoute.id === referenceRoute.id) {
if (currentRoute.id === referenceRouteIdentifier) {
matchingRoute = currentRoute;
matchingRouteParent = parentRoute;
matchingRoutePositionInParent = index;
@ -139,7 +151,9 @@ export function findRouteInTree(
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(
{
...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 {
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;
}

View File

@ -245,7 +245,7 @@ export function stringifyIdentifier(identifier: RuleIdentifier): string {
.join('$');
}
function hash(value: string): number {
export function hash(value: string): number {
let hash = 0;
if (value.length === 0) {
return hash;

View File

@ -2,6 +2,8 @@
import { DataSourceJsonData, WithAccessControlMetadata } from '@grafana/data';
import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/receiversApi.gen';
export const ROUTES_META_SYMBOL = Symbol('routes_metadata');
export type AlertManagerCortexConfig = {
template_files: Record<string, string>;
alertmanager_config: AlertmanagerConfig;
@ -133,7 +135,8 @@ export type Route = {
active_time_intervals?: string[];
/** only the root policy might have a provenance field defined */
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;
resourceVersion?: string;
name?: string;