341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
import { nth } from 'lodash';
|
||
|
||
import { locationService } from '@grafana/runtime';
|
||
import {
|
||
CloudRuleIdentifier,
|
||
CombinedRule,
|
||
EditableRuleIdentifier,
|
||
Rule,
|
||
RuleGroupIdentifier,
|
||
RuleGroupIdentifierV2,
|
||
RuleIdentifier,
|
||
RuleWithLocation,
|
||
} from 'app/types/unified-alerting';
|
||
import { Annotations, Labels, PromRuleType, RulerCloudRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||
|
||
import { logError } from '../Analytics';
|
||
import { shouldUsePrometheusRulesPrimary } from '../featureToggles';
|
||
|
||
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||
import {
|
||
isAlertingRule,
|
||
isAlertingRulerRule,
|
||
isCloudRuleIdentifier,
|
||
isGrafanaRuleIdentifier,
|
||
isGrafanaRulerRule,
|
||
isPrometheusRuleIdentifier,
|
||
isRecordingRule,
|
||
isRecordingRulerRule,
|
||
} from './rules';
|
||
|
||
export function fromRulerRule(
|
||
ruleSourceName: string,
|
||
namespace: string,
|
||
groupName: string,
|
||
rule: RulerRuleDTO
|
||
): EditableRuleIdentifier {
|
||
if (isGrafanaRulerRule(rule)) {
|
||
return { uid: rule.grafana_alert.uid!, ruleSourceName: 'grafana' };
|
||
}
|
||
return {
|
||
ruleSourceName,
|
||
namespace,
|
||
groupName,
|
||
ruleName: isAlertingRulerRule(rule) ? rule.alert : rule.record,
|
||
rulerRuleHash: hashRulerRule(rule),
|
||
} satisfies CloudRuleIdentifier;
|
||
}
|
||
|
||
export function fromRulerRuleAndGroupIdentifierV2(
|
||
ruleGroup: RuleGroupIdentifierV2,
|
||
rule: RulerRuleDTO
|
||
): EditableRuleIdentifier {
|
||
if (ruleGroup.groupOrigin === 'grafana') {
|
||
if (isGrafanaRulerRule(rule)) {
|
||
return { uid: rule.grafana_alert.uid, ruleSourceName: 'grafana' };
|
||
}
|
||
logError(new Error('Rule is not a Grafana Ruler rule'));
|
||
throw new Error('Rule is not a Grafana Ruler rule');
|
||
}
|
||
|
||
return fromRulerRule(ruleGroup.rulesSource.name, ruleGroup.namespace.name, ruleGroup.groupName, rule);
|
||
}
|
||
|
||
export function fromRulerRuleAndRuleGroupIdentifier(
|
||
ruleGroup: RuleGroupIdentifier,
|
||
rule: RulerRuleDTO
|
||
): EditableRuleIdentifier {
|
||
const { dataSourceName, namespaceName, groupName } = ruleGroup;
|
||
return fromRulerRule(dataSourceName, namespaceName, groupName, rule);
|
||
}
|
||
|
||
export function fromRule(ruleSourceName: string, namespace: string, groupName: string, rule: Rule): RuleIdentifier {
|
||
return {
|
||
ruleSourceName,
|
||
namespace,
|
||
groupName,
|
||
ruleName: rule.name,
|
||
ruleHash: hashRule(rule),
|
||
};
|
||
}
|
||
|
||
export function fromCombinedRule(ruleSourceName: string, rule: CombinedRule): RuleIdentifier {
|
||
const namespaceName = rule.namespace.name;
|
||
const groupName = rule.group.name;
|
||
|
||
if (rule.rulerRule) {
|
||
return fromRulerRule(ruleSourceName, namespaceName, groupName, rule.rulerRule);
|
||
}
|
||
|
||
if (rule.promRule) {
|
||
return fromRule(ruleSourceName, namespaceName, groupName, rule.promRule);
|
||
}
|
||
|
||
throw new Error('Could not create an id for a rule that is missing both `rulerRule` and `promRule`.');
|
||
}
|
||
|
||
export function fromRuleWithLocation(rule: RuleWithLocation): RuleIdentifier {
|
||
return fromRulerRule(rule.ruleSourceName, rule.namespace, rule.group.name, rule.rule);
|
||
}
|
||
|
||
export function equal(a: RuleIdentifier, b: RuleIdentifier) {
|
||
if (isGrafanaRuleIdentifier(a) && isGrafanaRuleIdentifier(b)) {
|
||
return a.uid === b.uid;
|
||
}
|
||
|
||
if (isCloudRuleIdentifier(a) && isCloudRuleIdentifier(b)) {
|
||
return (
|
||
a.groupName === b.groupName &&
|
||
a.namespace === b.namespace &&
|
||
a.ruleName === b.ruleName &&
|
||
a.rulerRuleHash === b.rulerRuleHash &&
|
||
a.ruleSourceName === b.ruleSourceName
|
||
);
|
||
}
|
||
|
||
if (isPrometheusRuleIdentifier(a) && isPrometheusRuleIdentifier(b)) {
|
||
return (
|
||
a.groupName === b.groupName &&
|
||
a.namespace === b.namespace &&
|
||
a.ruleName === b.ruleName &&
|
||
a.ruleHash === b.ruleHash &&
|
||
a.ruleSourceName === b.ruleSourceName
|
||
);
|
||
}
|
||
|
||
// It might happen to compare Cloud and Prometheus identifiers for datasources with available Ruler API
|
||
// It happends when the Ruler API timeouts and the UI cannot create Cloud identifiers, so it creates a Prometheus identifier instead.
|
||
if (isCloudRuleIdentifier(a) && isPrometheusRuleIdentifier(b)) {
|
||
return (
|
||
a.groupName === b.groupName &&
|
||
a.namespace === b.namespace &&
|
||
a.ruleName === b.ruleName &&
|
||
a.rulerRuleHash === b.ruleHash &&
|
||
a.ruleSourceName === b.ruleSourceName
|
||
);
|
||
}
|
||
|
||
if (isPrometheusRuleIdentifier(a) && isCloudRuleIdentifier(b)) {
|
||
return (
|
||
a.groupName === b.groupName &&
|
||
a.namespace === b.namespace &&
|
||
a.ruleName === b.ruleName &&
|
||
a.ruleHash === b.rulerRuleHash &&
|
||
a.ruleSourceName === b.ruleSourceName
|
||
);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
const cloudRuleIdentifierPrefix = 'cri';
|
||
const prometheusRuleIdentifierPrefix = 'pri';
|
||
|
||
function escapeDollars(value: string): string {
|
||
return value.replace(/\$/g, '_DOLLAR_');
|
||
}
|
||
|
||
function unescapeDollars(value: string): string {
|
||
return value.replace(/\_DOLLAR\_/g, '$');
|
||
}
|
||
|
||
/**
|
||
* deal with Unix-style path separators "/" (replaced with \x1f – unit separator)
|
||
* and Windows-style path separators "\" (replaced with \x1e – record separator)
|
||
* we need this to side-step proxies that automatically decode %2F to prevent path traversal attacks
|
||
* we'll use some non-printable characters from the ASCII table that will get encoded properly but very unlikely
|
||
* to ever be used in a rule name or namespace
|
||
*/
|
||
export function escapePathSeparators(value: string): string {
|
||
return value.replace(/\//g, '\x1f').replace(/\\/g, '\x1e');
|
||
}
|
||
|
||
export function unescapePathSeparators(value: string): string {
|
||
return value.replace(/\x1f/g, '/').replace(/\x1e/g, '\\');
|
||
}
|
||
|
||
export function parse(value: string, decodeFromUri = false): RuleIdentifier {
|
||
const source = decodeFromUri ? decodeURIComponent(value) : value;
|
||
const parts = source.split('$');
|
||
|
||
if (parts.length === 1) {
|
||
return { uid: value, ruleSourceName: 'grafana' };
|
||
}
|
||
|
||
if (parts.length === 6) {
|
||
const [prefix, ruleSourceName, namespace, groupName, ruleName, hash] = parts
|
||
.map(unescapeDollars)
|
||
.map(unescapePathSeparators);
|
||
|
||
if (prefix === cloudRuleIdentifierPrefix) {
|
||
return { ruleSourceName, namespace, groupName, ruleName, rulerRuleHash: hash };
|
||
}
|
||
|
||
if (prefix === prometheusRuleIdentifierPrefix) {
|
||
return { ruleSourceName, namespace, groupName, ruleName, ruleHash: hash };
|
||
}
|
||
}
|
||
|
||
throw new Error(`Failed to parse rule location: ${value}`);
|
||
}
|
||
|
||
export function tryParse(value: string | undefined, decodeFromUri = false): RuleIdentifier | undefined {
|
||
if (!value) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
return parse(value, decodeFromUri);
|
||
} catch (error) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
export function stringifyIdentifier(identifier: RuleIdentifier): string {
|
||
if (isGrafanaRuleIdentifier(identifier)) {
|
||
return identifier.uid;
|
||
}
|
||
|
||
if (isCloudRuleIdentifier(identifier)) {
|
||
return [
|
||
cloudRuleIdentifierPrefix,
|
||
identifier.ruleSourceName,
|
||
identifier.namespace,
|
||
identifier.groupName,
|
||
identifier.ruleName,
|
||
identifier.rulerRuleHash,
|
||
]
|
||
.map(String)
|
||
.map(escapeDollars)
|
||
.map(escapePathSeparators)
|
||
.join('$');
|
||
}
|
||
|
||
return [
|
||
prometheusRuleIdentifierPrefix,
|
||
identifier.ruleSourceName,
|
||
identifier.namespace,
|
||
identifier.groupName,
|
||
identifier.ruleName,
|
||
identifier.ruleHash,
|
||
]
|
||
.map(String)
|
||
.map(escapeDollars)
|
||
.map(escapePathSeparators)
|
||
.join('$');
|
||
}
|
||
|
||
export function hash(value: string): number {
|
||
let hash = 0;
|
||
if (value.length === 0) {
|
||
return hash;
|
||
}
|
||
for (let i = 0; i < value.length; i++) {
|
||
const char = value.charCodeAt(i);
|
||
hash = (hash << 5) - hash + char;
|
||
hash = hash & hash; // Convert to 32bit integer
|
||
}
|
||
return hash;
|
||
}
|
||
|
||
// this is used to identify rules, mimir / loki rules do not have a unique identifier
|
||
export function hashRulerRule(rule: RulerRuleDTO): string {
|
||
if (isGrafanaRulerRule(rule)) {
|
||
return rule.grafana_alert.uid;
|
||
}
|
||
|
||
const fingerprint = getRulerRuleFingerprint(rule);
|
||
return hash(JSON.stringify(fingerprint)).toString();
|
||
}
|
||
|
||
function getRulerRuleFingerprint(rule: RulerCloudRuleDTO) {
|
||
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||
// If the prometheusRulesPrimary feature toggle is enabled, we don't need to hash the query
|
||
// We need to make fingerprint compatibility between Prometheus and Ruler rules
|
||
// Query often differs between the two, so we can't use it to generate a fingerprint
|
||
const queryHash = prometheusRulesPrimary ? '' : hashQuery(rule.expr);
|
||
const labelsHash = hashLabelsOrAnnotations(rule.labels);
|
||
|
||
if (isRecordingRulerRule(rule)) {
|
||
return [rule.record, PromRuleType.Recording, queryHash, labelsHash];
|
||
}
|
||
if (isAlertingRulerRule(rule)) {
|
||
return [rule.alert, PromRuleType.Alerting, queryHash, hashLabelsOrAnnotations(rule.annotations), labelsHash];
|
||
}
|
||
throw new Error('Only recording and alerting ruler rules can be hashed');
|
||
}
|
||
|
||
export function hashRule(rule: Rule): string {
|
||
const fingerprint = getPromRuleFingerprint(rule);
|
||
return hash(JSON.stringify(fingerprint)).toString();
|
||
}
|
||
|
||
function getPromRuleFingerprint(rule: Rule) {
|
||
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||
|
||
const queryHash = prometheusRulesPrimary ? '' : hashQuery(rule.query);
|
||
const labelsHash = hashLabelsOrAnnotations(rule.labels);
|
||
|
||
if (isRecordingRule(rule)) {
|
||
return [rule.name, PromRuleType.Recording, queryHash, labelsHash];
|
||
}
|
||
if (isAlertingRule(rule)) {
|
||
return [rule.name, PromRuleType.Alerting, queryHash, hashLabelsOrAnnotations(rule.annotations), labelsHash];
|
||
}
|
||
throw new Error('Only recording and alerting rules can be hashed');
|
||
}
|
||
|
||
// there can be slight differences in how prom & ruler render a query, this will hash them accounting for the differences
|
||
export function hashQuery(query: string) {
|
||
// one of them might be wrapped in parens
|
||
if (query.length > 1 && query[0] === '(' && query[query.length - 1] === ')') {
|
||
query = query.slice(1, -1);
|
||
}
|
||
// whitespace could be added or removed
|
||
query = query.replace(/\s|\n/g, '');
|
||
// labels matchers can be reordered, so sort the enitre string, esentially comparing just the character counts
|
||
return query.split('').sort().join('');
|
||
}
|
||
|
||
export function hashLabelsOrAnnotations(item: Labels | Annotations | undefined): string {
|
||
return JSON.stringify(Object.entries(item || {}).sort((a, b) => a[0].localeCompare(b[0])));
|
||
}
|
||
|
||
export function ruleIdentifierToRuleSourceName(identifier: RuleIdentifier): string {
|
||
return isGrafanaRuleIdentifier(identifier) ? GRAFANA_RULES_SOURCE_NAME : identifier.ruleSourceName;
|
||
}
|
||
|
||
// DO NOT USE REACT-ROUTER HOOKS FOR THIS CODE
|
||
// React-router's useLocation/useParams/props.match are broken and don't preserve original param values when parsing location
|
||
// so, they cannot be used to parse name and sourceName path params
|
||
// React-router messes the pathname up resulting in a string that is neither encoded nor decoded
|
||
// Relevant issue: https://github.com/remix-run/history/issues/505#issuecomment-453175833
|
||
// It was probably fixed in React-Router v6
|
||
type PathWithOptionalID = { id?: string };
|
||
export function getRuleIdFromPathname(params: PathWithOptionalID): string | undefined {
|
||
const { pathname = '' } = locationService.getLocation();
|
||
const { id } = params;
|
||
|
||
return id ? nth(pathname.split('/'), -2) : undefined;
|
||
}
|