/** * Various helper functions to modify (immutably) the route tree, aka "notification policies" */ import { produce } from 'immer'; import { omit } from 'lodash'; import { insertAfterImmutably, insertBeforeImmutably } from '@grafana/data/src/utils/arrayUtils'; 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 = ( alertManagerSourceName: string, partialFormRoute: Partial, routeTree: RouteWithID ): Route => { const existing = findExistingRoute(partialFormRoute.id ?? '', routeTree); if (!existing) { 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 { let updatedRoute: Route = currentRoute; if (currentRoute.id === partialFormRoute.id) { const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree); updatedRoute = { ...currentRoute, ...newRoute, }; } return { ...updatedRoute, routes: currentRoute.routes?.map(findAndReplace), }; } return findAndReplace(routeTree); }; // 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 = (id: string, routeTree: RouteWithID): RouteWithID => { if (id === routeTree.id) { throw new Error('You cant remove the root policy'); } function findAndOmit(currentRoute: RouteWithID): RouteWithID { return { ...currentRoute, routes: currentRoute.routes?.reduce((acc: RouteWithID[] = [], route) => { if (route.id === id) { return acc; } acc.push(findAndOmit(route)); return acc; }, []), }; } return findAndOmit(routeTree); }; export type InsertPosition = 'above' | 'below' | 'child'; // add a new route to a parent route export const addRouteToReferenceRoute = ( alertManagerSourceName: string, partialFormRoute: Partial, referenceRouteIdentifier: string, routeTree: RouteWithID, position: InsertPosition ): RouteWithID => { const newRoute = formAmRouteToAmRoute(alertManagerSourceName, partialFormRoute, routeTree); return produce(routeTree, (draftTree) => { const [routeInTree, parentRoute, positionInParent] = findRouteInTree(draftTree, referenceRouteIdentifier); if (routeInTree === undefined || parentRoute === undefined || positionInParent === undefined) { 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 (position === 'child') { if (routeInTree.routes) { routeInTree.routes.push(newRoute); } else { routeInTree.routes = [newRoute]; } } // insert new policy before / above the referenceRoute if (position === 'above') { parentRoute.routes = insertBeforeImmutably(parentRoute.routes ?? [], newRoute, positionInParent); } // insert new policy after / below the referenceRoute if (position === 'below') { parentRoute.routes = insertAfterImmutably(parentRoute.routes ?? [], newRoute, positionInParent); } }); }; type RouteMatch = Route | undefined; export function findRouteInTree( routeTree: RouteWithID, referenceRouteIdentifier: string ): [matchingRoute: RouteMatch, parentRoute: RouteMatch, positionInParent: number | undefined] { let matchingRoute: RouteMatch; let matchingRouteParent: RouteMatch; let matchingRoutePositionInParent: number | undefined; // recurse through the tree to find the matching route, its parent and the position of the route in the parent function findRouteInTree(currentRoute: RouteWithID, index: number, parentRoute: RouteWithID) { if (matchingRoute) { return; } if (currentRoute.id === referenceRouteIdentifier) { matchingRoute = currentRoute; matchingRouteParent = parentRoute; matchingRoutePositionInParent = index; } if (currentRoute.routes) { currentRoute.routes.forEach((route, index) => findRouteInTree(route, index, currentRoute)); } } findRouteInTree(routeTree, 0, routeTree); return [matchingRoute, matchingRouteParent, matchingRoutePositionInParent]; } export function cleanRouteIDs< T extends RouteWithID | Route | ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route, >(route: T): Omit { return omit( { ...route, routes: route.routes?.map((route) => cleanRouteIDs(route)), }, 'id' ); } // 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 { const result: Required = { 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): Record { const sorted: Record = {}; Object.keys(record) .sort() .forEach((key) => { sorted[key] = record[key]; }); return sorted; }