@intentius/chant-lexicon-grafana 0.105.1 → 0.106.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/detect.d.ts +8 -0
- package/dist/detect.d.ts.map +1 -1
- package/dist/import/embedded.d.ts.map +1 -1
- package/dist/import/operator.d.ts +58 -0
- package/dist/import/operator.d.ts.map +1 -0
- package/dist/integrity.json +3 -3
- package/dist/k8s.d.ts +89 -11
- package/dist/k8s.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/skills/chant-grafana-provisioning.md +1 -1
- package/package.json +2 -2
- package/src/detect.ts +21 -0
- package/src/import/embedded.test.ts +9 -1
- package/src/import/embedded.ts +5 -6
- package/src/import/operator.test.ts +235 -0
- package/src/import/operator.ts +243 -0
- package/src/k8s.test.ts +190 -1
- package/src/k8s.ts +323 -10
- package/src/plugin.ts +2 -1
- package/src/skills/chant-grafana-provisioning.md +1 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { EmbeddedContent } from "@intentius/chant/import/embedded";
|
|
3
|
+
import { grafanaPlugin } from "../plugin";
|
|
4
|
+
import { operatorContactPoint, operatorPolicyRoute, operatorRouteSpec, operatorRule } from "../k8s";
|
|
5
|
+
import { operatorImporter, provisionedReceivers, provisionedRoute, provisionedRule } from "./operator";
|
|
6
|
+
|
|
7
|
+
const site = (hostType: string, select: string, spec: Record<string, unknown>): EmbeddedContent => ({
|
|
8
|
+
host: "k8s",
|
|
9
|
+
hostType,
|
|
10
|
+
location: `${hostType.split("::").pop()} x spec.${select}`,
|
|
11
|
+
directory: "x",
|
|
12
|
+
document: spec,
|
|
13
|
+
select,
|
|
14
|
+
labels: {},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const SELECTOR = { matchLabels: { dashboards: "grafana" } };
|
|
18
|
+
|
|
19
|
+
describe("Grafana Operator alerting embedded in a k8s manifest (#3538)", () => {
|
|
20
|
+
test("the plugin registers the importer", () => {
|
|
21
|
+
expect(grafanaPlugin.embeddedImporters?.()).toContain(operatorImporter);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("matches the five kinds at their own field, and nothing else", () => {
|
|
25
|
+
expect(operatorImporter.matches(site("K8s::Grafana::GrafanaAlertRuleGroup", "rules", { rules: [] }))).toBe(true);
|
|
26
|
+
expect(operatorImporter.matches(site("K8s::Grafana::GrafanaContactPoint", "receivers", { receivers: [] }))).toBe(true);
|
|
27
|
+
expect(operatorImporter.matches(site("K8s::Grafana::GrafanaNotificationPolicy", "route", { route: {} }))).toBe(true);
|
|
28
|
+
expect(operatorImporter.matches(site("K8s::Grafana::GrafanaNotificationPolicyRoute", "route", { route: { receiver: "r" } }))).toBe(true);
|
|
29
|
+
expect(operatorImporter.matches(site("K8s::Grafana::GrafanaMuteTiming", "time_intervals", { time_intervals: [] }))).toBe(true);
|
|
30
|
+
expect(operatorImporter.matches(site("K8s::Grafana::GrafanaNotificationTemplate", "template", { template: "x" }))).toBe(true);
|
|
31
|
+
expect(operatorImporter.matches(site("K8s::Grafana::GrafanaAlertRuleGroup", "receivers", {}))).toBe(false);
|
|
32
|
+
expect(operatorImporter.matches(site("K8s::Core::ConfigMap", "rules", { rules: [] }))).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("a rule", () => {
|
|
36
|
+
test("goes back to the file's spelling, without the values the CRD requires and Grafana defaults", () => {
|
|
37
|
+
expect(
|
|
38
|
+
provisionedRule({
|
|
39
|
+
uid: "r",
|
|
40
|
+
title: "R",
|
|
41
|
+
condition: "A",
|
|
42
|
+
data: [],
|
|
43
|
+
for: "0s",
|
|
44
|
+
noDataState: "NoData",
|
|
45
|
+
execErrState: "Alerting",
|
|
46
|
+
missingSeriesEvalsToResolve: 3,
|
|
47
|
+
notificationSettings: { receiver: "oncall", group_by: ["a"] },
|
|
48
|
+
annotations: { __dashboardUid__: "dash", __panelId__: "2", summary: "s" },
|
|
49
|
+
}),
|
|
50
|
+
).toEqual({
|
|
51
|
+
uid: "r",
|
|
52
|
+
title: "R",
|
|
53
|
+
condition: "A",
|
|
54
|
+
data: [],
|
|
55
|
+
missing_series_evals_to_resolve: 3,
|
|
56
|
+
notification_settings: { receiver: "oncall", group_by: ["a"] },
|
|
57
|
+
dashboardUid: "dash",
|
|
58
|
+
panelId: 2,
|
|
59
|
+
annotations: { summary: "s" },
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("keeps values that are not the defaults, and a record rule's condition is the rule's own", () => {
|
|
64
|
+
const back = provisionedRule({ uid: "r", title: "R", condition: "B", data: [], for: "5m", noDataState: "Alerting", execErrState: "Error" });
|
|
65
|
+
expect(back).toMatchObject({ for: "5m", noDataState: "Alerting", execErrState: "Error", condition: "B" });
|
|
66
|
+
expect(provisionedRule({ uid: "r", title: "R", condition: "A", data: [], record: { metric: "m", from: "A" } })).not.toHaveProperty("condition");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("is the inverse of operatorRule", () => {
|
|
70
|
+
const rule = {
|
|
71
|
+
uid: "r",
|
|
72
|
+
title: "R",
|
|
73
|
+
condition: "A",
|
|
74
|
+
data: [],
|
|
75
|
+
for: "5m",
|
|
76
|
+
labels: { severity: "page" },
|
|
77
|
+
dashboardUid: "dash",
|
|
78
|
+
panelId: 4,
|
|
79
|
+
annotations: { summary: "s" },
|
|
80
|
+
missing_series_evals_to_resolve: 2,
|
|
81
|
+
notification_settings: { receiver: "oncall" },
|
|
82
|
+
};
|
|
83
|
+
expect(provisionedRule(operatorRule(rule))).toEqual(rule);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("a receiver", () => {
|
|
88
|
+
test("reads each valuesFrom entry back as ${NAME} at its path, and names the Secret", () => {
|
|
89
|
+
const forward = operatorContactPoint(
|
|
90
|
+
{ name: "oncall", receivers: [{ uid: "u", type: "slack", settings: { title: "t", url: "${SLACK_URL}", tlsConfig: { clientKey: "$KEY" } } }] },
|
|
91
|
+
"grafana-secrets",
|
|
92
|
+
);
|
|
93
|
+
const back = provisionedReceivers(forward.receivers as unknown[], "contact point");
|
|
94
|
+
expect(back.secretName).toBe("grafana-secrets");
|
|
95
|
+
expect(back.receivers).toEqual([{ uid: "u", type: "slack", settings: { title: "t", url: "${SLACK_URL}", tlsConfig: { clientKey: "${KEY}" } } }]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("without valuesFrom there is no Secret", () => {
|
|
99
|
+
expect(provisionedReceivers([{ uid: "m", type: "email", settings: { addresses: "a@example.com" } }], "cp")).toEqual({
|
|
100
|
+
receivers: [{ uid: "m", type: "email", settings: { addresses: "a@example.com" } }],
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("refuses what a ${NAME} reference cannot say", () => {
|
|
105
|
+
const from = (valueFrom: unknown, name = "s") => [{ type: "x", settings: {}, valuesFrom: [{ targetPath: "url", valueFrom }, { targetPath: "b", valueFrom: { secretKeyRef: { name, key: "B" } } }] }];
|
|
106
|
+
expect(() => provisionedReceivers(from({ configMapKeyRef: { name: "c", key: "K" } }), "cp")).toThrow(/secretKeyRef/);
|
|
107
|
+
expect(() => provisionedReceivers(from({ secretKeyRef: { name: "s", key: "not-a-name" } }), "cp")).toThrow(/secretKeyRef/);
|
|
108
|
+
expect(() => provisionedReceivers(from({ secretKeyRef: { name: "one", key: "A" } }, "two"), "cp")).toThrow(/one secretName/);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("the policy tree", () => {
|
|
113
|
+
test("object_matchers go back to matchers in every nested route", () => {
|
|
114
|
+
const route = operatorPolicyRoute({
|
|
115
|
+
receiver: "oncall",
|
|
116
|
+
routes: [{ receiver: "tickets", matchers: ['severity="ticket"', "team=~a|b"], routes: [{ matchers: ["env!=dev"] }] }],
|
|
117
|
+
});
|
|
118
|
+
expect(provisionedRoute(route)).toEqual({
|
|
119
|
+
receiver: "oncall",
|
|
120
|
+
routes: [{ receiver: "tickets", matchers: ['severity="ticket"', 'team=~"a|b"'], routes: [{ matchers: ['env!="dev"'] }] }],
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a matcher that cannot be written as a string keeps them all as objects", () => {
|
|
125
|
+
const objects = [
|
|
126
|
+
["a", "=", "x"],
|
|
127
|
+
["b", "=", 'say "hi"'],
|
|
128
|
+
];
|
|
129
|
+
expect(provisionedRoute({ receiver: "r", object_matchers: objects })).toEqual({ receiver: "r", object_matchers: objects });
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("import", () => {
|
|
134
|
+
test("a rule group becomes an AlertRuleGroup, read through operatorRules, with a warning about its folder", () => {
|
|
135
|
+
const spec = {
|
|
136
|
+
instanceSelector: SELECTOR,
|
|
137
|
+
name: "errors",
|
|
138
|
+
folderRef: "grafana-folder-alerts",
|
|
139
|
+
interval: "30s",
|
|
140
|
+
rules: [
|
|
141
|
+
{
|
|
142
|
+
uid: "high-errors",
|
|
143
|
+
title: "High errors",
|
|
144
|
+
condition: "A",
|
|
145
|
+
for: "5m",
|
|
146
|
+
noDataState: "NoData",
|
|
147
|
+
execErrState: "Alerting",
|
|
148
|
+
data: [{ refId: "A", relativeTimeRange: { from: 600, to: 0 }, datasourceUid: "prom", model: { refId: "A", expr: "up", datasource: { type: "prometheus", uid: "prom" } } }],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
const out = operatorImporter.import(site("K8s::Grafana::GrafanaAlertRuleGroup", "rules", spec));
|
|
153
|
+
expect(out.value.shape).toBe("single");
|
|
154
|
+
expect(out.value.through).toEqual({ from: "@intentius/chant-lexicon-grafana/k8s", name: "operatorRules" });
|
|
155
|
+
const [binding] = out.value.bindings;
|
|
156
|
+
expect(out.files.find((f) => f.path === binding.from)?.content).toContain(`const ${binding.name} = new AlertRuleGroup(`);
|
|
157
|
+
const text = out.files.map((f) => f.content).join("\n");
|
|
158
|
+
expect(text).toContain('folder: "grafana-folder-alerts"');
|
|
159
|
+
expect(text).toContain("new AlertRule(");
|
|
160
|
+
expect(out.warnings?.join("\n")).toContain('rule group "errors": a declaration\'s folder is a title');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a contact point that reads a Secret gets a module naming it", () => {
|
|
164
|
+
const spec = {
|
|
165
|
+
instanceSelector: SELECTOR,
|
|
166
|
+
name: "oncall",
|
|
167
|
+
receivers: [
|
|
168
|
+
{
|
|
169
|
+
uid: "oncall-slack",
|
|
170
|
+
type: "slack",
|
|
171
|
+
settings: { title: "chant" },
|
|
172
|
+
valuesFrom: [{ targetPath: "url", valueFrom: { secretKeyRef: { name: "grafana-secrets", key: "SLACK_URL" } } }],
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
const out = operatorImporter.import(site("K8s::Grafana::GrafanaContactPoint", "receivers", spec));
|
|
177
|
+
expect(out.value.through).toBeUndefined();
|
|
178
|
+
const [binding] = out.value.bindings;
|
|
179
|
+
expect(binding).toEqual({ from: "receivers.ts", name: "receivers" });
|
|
180
|
+
const module = out.files.find((f) => f.path === "receivers.ts")!.content;
|
|
181
|
+
expect(module).toContain('import { operatorReceivers } from "@intentius/chant-lexicon-grafana/k8s";');
|
|
182
|
+
expect(module).toContain('"grafana-secrets"');
|
|
183
|
+
expect(out.files.map((f) => f.content).join("\n")).toContain("${SLACK_URL}");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a contact point with no Secret is read through operatorReceivers alone", () => {
|
|
187
|
+
const spec = { name: "mail", receivers: [{ uid: "m", type: "email", settings: { addresses: "a@example.com" } }] };
|
|
188
|
+
const out = operatorImporter.import(site("K8s::Grafana::GrafanaContactPoint", "receivers", spec));
|
|
189
|
+
expect(out.value.through).toEqual({ from: "@intentius/chant-lexicon-grafana/k8s", name: "operatorReceivers" });
|
|
190
|
+
expect(out.files.some((f) => f.path === "receivers.ts")).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("a policy, a mute timing and a template", () => {
|
|
194
|
+
const policy = operatorImporter.import(site("K8s::Grafana::GrafanaNotificationPolicy", "route", { route: { receiver: "oncall", routes: [{ object_matchers: [["severity", "=", "page"]] }] } }));
|
|
195
|
+
expect(policy.value.through?.name).toBe("operatorPolicy");
|
|
196
|
+
const text = policy.files.map((f) => f.content).join("\n");
|
|
197
|
+
expect(text).toContain("new NotificationPolicy(");
|
|
198
|
+
expect(text).toContain('severity="page"');
|
|
199
|
+
|
|
200
|
+
const mute = operatorImporter.import(site("K8s::Grafana::GrafanaMuteTiming", "time_intervals", { name: "weekends", time_intervals: [{ weekdays: ["saturday", "sunday"] }] }));
|
|
201
|
+
expect(mute.value.through?.name).toBe("operatorTimeIntervals");
|
|
202
|
+
expect(mute.files.map((f) => f.content).join("\n")).toContain("new MuteTiming(");
|
|
203
|
+
|
|
204
|
+
const template = operatorImporter.import(site("K8s::Grafana::GrafanaNotificationTemplate", "template", { name: "t", template: '{{ define "t" }}x{{ end }}' }));
|
|
205
|
+
expect(template.value.through?.name).toBe("operatorTemplate");
|
|
206
|
+
expect(template.files.map((f) => f.content).join("\n")).toContain("new NotificationTemplate(");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("a policy route is read as the child of a policy and written back through operatorRouteSpec", () => {
|
|
210
|
+
const spec = { receiver: "tickets", object_matchers: [["severity", "=", "ticket"]], mute_time_intervals: ["weekends"], routes: [{ object_matchers: [["team", "=", "a"]] }] };
|
|
211
|
+
const out = operatorImporter.import(site("K8s::Grafana::GrafanaNotificationPolicyRoute", "route", { route: spec }));
|
|
212
|
+
expect(out.value.through).toEqual({ from: "@intentius/chant-lexicon-grafana/k8s", name: "operatorRouteSpec" });
|
|
213
|
+
const text = out.files.map((f) => f.content).join("\n");
|
|
214
|
+
expect(text).toContain("new NotificationPolicy(");
|
|
215
|
+
expect(text).toContain('severity="ticket"');
|
|
216
|
+
expect(text).toContain('team="a"');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a policy route's routeSelector is left out with a warning, and a route with no receiver throws", () => {
|
|
220
|
+
const out = operatorImporter.import(site("K8s::Grafana::GrafanaNotificationPolicyRoute", "route", { route: { receiver: "r", routeSelector: { matchLabels: { a: "b" } } } }));
|
|
221
|
+
expect((out.warnings ?? []).join("\n")).toContain("routeSelector");
|
|
222
|
+
expect(() => operatorImporter.import(site("K8s::Grafana::GrafanaNotificationPolicyRoute", "route", { route: { matchers: [] } }))).toThrow(/spec.receiver/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("a policy that selects routes by label throws so the host keeps it as written", () => {
|
|
226
|
+
const route = { receiver: "oncall", routeSelector: { matchLabels: { a: "b" } } };
|
|
227
|
+
expect(() => operatorImporter.import(site("K8s::Grafana::GrafanaNotificationPolicy", "route", { route }))).toThrow(/routeSelector/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("a policy with no receiver, and a group with no folder, throw so the host keeps them as written", () => {
|
|
231
|
+
expect(() => operatorImporter.import(site("K8s::Grafana::GrafanaNotificationPolicy", "route", { route: { group_by: ["a"] } }))).toThrow();
|
|
232
|
+
expect(() => operatorImporter.import(site("K8s::Grafana::GrafanaAlertRuleGroup", "rules", { name: "g", rules: [] }))).toThrow(/names no folder/);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grafana Operator alerting resources embedded in a k8s manifest, for
|
|
3
|
+
* `chant import` (#3538).
|
|
4
|
+
*
|
|
5
|
+
* `GrafanaOperatorResources` (#3156) writes `GrafanaAlertRuleGroup`,
|
|
6
|
+
* `GrafanaContactPoint`, `GrafanaNotificationPolicy` (with, for
|
|
7
|
+
* `policyRoutes`, a `GrafanaNotificationPolicyRoute` per child route),
|
|
8
|
+
* `GrafanaMuteTiming` and
|
|
9
|
+
* `GrafanaNotificationTemplate` from grafana alerting declarations. The k8s
|
|
10
|
+
* parser offers the one field of each that holds the alerting content
|
|
11
|
+
* (`spec.rules`, `spec.receivers`, `spec.route`, `spec.time_intervals`,
|
|
12
|
+
* `spec.template`) with the whole `spec` as the document. This importer
|
|
13
|
+
* reads that spec back into the provisioning-file shape, which the alerting
|
|
14
|
+
* importer turns into declarations, and the k8s value becomes a call of the
|
|
15
|
+
* matching function in `@intentius/chant-lexicon-grafana/k8s` over the
|
|
16
|
+
* declaration, so a build gives the same field again.
|
|
17
|
+
*
|
|
18
|
+
* The reverse of what `GrafanaOperatorResources` writes:
|
|
19
|
+
*
|
|
20
|
+
* - a rule: the CRD's camel case (`missingSeriesEvalsToResolve`,
|
|
21
|
+
* `notificationSettings`) goes back to the file's snake case, the
|
|
22
|
+
* `__dashboardUid__` and `__panelId__` annotations go back to
|
|
23
|
+
* `dashboardUid` and `panelId`, and the values the CRD requires and the
|
|
24
|
+
* file leaves to Grafana's defaults (`for: 0s`, `noDataState: NoData`,
|
|
25
|
+
* `execErrState: Alerting`) are left out;
|
|
26
|
+
* - a receiver: each `valuesFrom` entry becomes `${NAME}` at its
|
|
27
|
+
* `targetPath` in the settings, `NAME` being the Secret key. The Secret's
|
|
28
|
+
* name is not part of a declaration, so a contact point that reads a
|
|
29
|
+
* Secret is written with a small module holding
|
|
30
|
+
* `operatorReceivers(contactPoint, "<secret>")`;
|
|
31
|
+
* - a `GrafanaNotificationPolicyRoute`: its whole `spec` is the route, read
|
|
32
|
+
* as the one child of a policy that has its receiver, and written back by
|
|
33
|
+
* `operatorRouteSpec`;
|
|
34
|
+
* - the policy tree: `object_matchers` go back to Alertmanager `matchers`
|
|
35
|
+
* strings where each can be written as one.
|
|
36
|
+
*
|
|
37
|
+
* A rule group's folder is a title in a declaration and a `GrafanaFolder`
|
|
38
|
+
* name (`folderRef`) in the resource, and the resource does not hold the
|
|
39
|
+
* title, so the declaration's `folder` is the reference, with a warning.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { EmbeddedContent, EmbeddedContentImporter, EmbeddedImport } from "@intentius/chant/import/embedded";
|
|
43
|
+
import { planAlertingProvisioning } from "./alerting";
|
|
44
|
+
import { generatePlanModules } from "./generator";
|
|
45
|
+
import { isObject } from "./normalize";
|
|
46
|
+
|
|
47
|
+
type Json = Record<string, unknown>;
|
|
48
|
+
|
|
49
|
+
const K8S_ENTRY = "@intentius/chant-lexicon-grafana/k8s";
|
|
50
|
+
|
|
51
|
+
/** The resource types this importer reads, the spec field the k8s parser offers for each, the class it becomes and the function that writes the field back. */
|
|
52
|
+
const KINDS: Record<string, { select: string; className: string; through: string }> = {
|
|
53
|
+
"K8s::Grafana::GrafanaAlertRuleGroup": { select: "rules", className: "AlertRuleGroup", through: "operatorRules" },
|
|
54
|
+
"K8s::Grafana::GrafanaContactPoint": { select: "receivers", className: "ContactPoint", through: "operatorReceivers" },
|
|
55
|
+
"K8s::Grafana::GrafanaNotificationPolicy": { select: "route", className: "NotificationPolicy", through: "operatorPolicy" },
|
|
56
|
+
"K8s::Grafana::GrafanaNotificationPolicyRoute": { select: "route", className: "NotificationPolicy", through: "operatorRouteSpec" },
|
|
57
|
+
"K8s::Grafana::GrafanaMuteTiming": { select: "time_intervals", className: "MuteTiming", through: "operatorTimeIntervals" },
|
|
58
|
+
"K8s::Grafana::GrafanaNotificationTemplate": { select: "template", className: "NotificationTemplate", through: "operatorTemplate" },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** A Secret key a `${NAME}` reference can name. */
|
|
62
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
63
|
+
|
|
64
|
+
// ── the reverse mapping ──────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/** One entry of `GrafanaAlertRuleGroup.spec.rules` as a rule of the provisioning file. */
|
|
67
|
+
export function provisionedRule(rule: Json): Json {
|
|
68
|
+
const { missingSeriesEvalsToResolve, notificationSettings, annotations, for: forDuration, noDataState, execErrState, condition, ...rest } = rule;
|
|
69
|
+
const out: Json = { ...rest };
|
|
70
|
+
|
|
71
|
+
const record = isObject(rule.record) ? rule.record : undefined;
|
|
72
|
+
if (condition !== undefined && !(record && condition === record.from)) out.condition = condition;
|
|
73
|
+
if (forDuration !== undefined && forDuration !== "0s") out.for = forDuration;
|
|
74
|
+
if (noDataState !== undefined && noDataState !== "NoData") out.noDataState = noDataState;
|
|
75
|
+
if (execErrState !== undefined && execErrState !== "Alerting") out.execErrState = execErrState;
|
|
76
|
+
if (missingSeriesEvalsToResolve !== undefined) out.missing_series_evals_to_resolve = missingSeriesEvalsToResolve;
|
|
77
|
+
if (notificationSettings !== undefined) out.notification_settings = notificationSettings;
|
|
78
|
+
|
|
79
|
+
if (isObject(annotations)) {
|
|
80
|
+
const { __dashboardUid__, __panelId__, ...notes } = annotations;
|
|
81
|
+
const kept: Json = { ...notes };
|
|
82
|
+
if (typeof __dashboardUid__ === "string") out.dashboardUid = __dashboardUid__;
|
|
83
|
+
else if (__dashboardUid__ !== undefined) kept.__dashboardUid__ = __dashboardUid__;
|
|
84
|
+
if (typeof __panelId__ === "string" && /^\d+$/.test(__panelId__)) out.panelId = Number(__panelId__);
|
|
85
|
+
else if (__panelId__ !== undefined) kept.__panelId__ = __panelId__;
|
|
86
|
+
if (Object.keys(kept).length > 0) out.annotations = kept;
|
|
87
|
+
} else if (annotations !== undefined) {
|
|
88
|
+
out.annotations = annotations;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function setPath(settings: Json, path: string[], value: string, where: string): void {
|
|
94
|
+
let node = settings;
|
|
95
|
+
for (const seg of path.slice(0, -1)) {
|
|
96
|
+
if (node[seg] === undefined) node[seg] = {};
|
|
97
|
+
if (!isObject(node[seg])) throw new Error(`${where}: valuesFrom targetPath ${path.join(".")} goes through ${seg}, which is not an object`);
|
|
98
|
+
node = node[seg] as Json;
|
|
99
|
+
}
|
|
100
|
+
node[path[path.length - 1]] = value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `GrafanaContactPoint.spec.receivers` as the provisioning file's receivers,
|
|
105
|
+
* each `valuesFrom` entry put back as `${NAME}` in the settings, and the name
|
|
106
|
+
* of the Secret they read (one Secret, as `GrafanaOperatorResources` writes).
|
|
107
|
+
*/
|
|
108
|
+
export function provisionedReceivers(receivers: unknown[], where: string): { receivers: Json[]; secretName?: string } {
|
|
109
|
+
const secrets = new Set<string>();
|
|
110
|
+
const out = receivers.map((r, i) => {
|
|
111
|
+
if (!isObject(r)) throw new Error(`${where}: receivers[${i}] is not an object`);
|
|
112
|
+
const { valuesFrom, ...rest } = r;
|
|
113
|
+
const settings: Json = isObject(rest.settings) ? structuredClone(rest.settings) : {};
|
|
114
|
+
for (const entry of Array.isArray(valuesFrom) ? valuesFrom : []) {
|
|
115
|
+
const ref = isObject(entry) && isObject(entry.valueFrom) && isObject(entry.valueFrom.secretKeyRef) ? entry.valueFrom.secretKeyRef : undefined;
|
|
116
|
+
if (!isObject(entry) || typeof entry.targetPath !== "string" || !ref || typeof ref.name !== "string" || typeof ref.key !== "string" || !ENV_NAME.test(ref.key)) {
|
|
117
|
+
throw new Error(`${where}: receivers[${i}] has a valuesFrom entry that is not a secretKeyRef to a key a \${NAME} reference can name`);
|
|
118
|
+
}
|
|
119
|
+
secrets.add(ref.name);
|
|
120
|
+
setPath(settings, entry.targetPath.split("."), `\${${ref.key}}`, where);
|
|
121
|
+
}
|
|
122
|
+
return { ...rest, settings };
|
|
123
|
+
});
|
|
124
|
+
if (secrets.size > 1) {
|
|
125
|
+
throw new Error(`${where}: the receivers read ${[...secrets].map((s) => `"${s}"`).join(" and ")}, and GrafanaOperatorResources takes one secretName`);
|
|
126
|
+
}
|
|
127
|
+
return { receivers: out, ...(secrets.size === 1 ? { secretName: [...secrets][0] } : {}) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** An `object_matchers` entry as an Alertmanager matcher string, when it can be written as one and read back the same. */
|
|
131
|
+
function matcherText(m: unknown): string | undefined {
|
|
132
|
+
if (!Array.isArray(m) || m.length !== 3 || !m.every((x) => typeof x === "string")) return undefined;
|
|
133
|
+
const [label, op, value] = m as [string, string, string];
|
|
134
|
+
if (!/^[^\s=!~]+$/.test(label) || !["=", "!=", "=~", "!~"].includes(op)) return undefined;
|
|
135
|
+
if (value.includes('"') || value.includes("\\")) return undefined;
|
|
136
|
+
return `${label}${op}"${value}"`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A route of the policy tree as the provisioning file holds it: `object_matchers` as `matchers` strings, in every nested route. */
|
|
140
|
+
export function provisionedRoute(route: Json): Json {
|
|
141
|
+
const { object_matchers, routes, ...rest } = route;
|
|
142
|
+
const out: Json = { ...rest };
|
|
143
|
+
if (Array.isArray(object_matchers) && object_matchers.length > 0) {
|
|
144
|
+
const texts = object_matchers.map(matcherText);
|
|
145
|
+
// Every matcher or none: the build writes object matchers before matchers, so a mix would reorder.
|
|
146
|
+
if (texts.every((t): t is string => t !== undefined)) out.matchers = [...texts, ...(Array.isArray(rest.matchers) ? (rest.matchers as unknown[]) : [])];
|
|
147
|
+
else out.object_matchers = object_matchers;
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(routes)) out.routes = routes.map((r) => (isObject(r) ? provisionedRoute(r) : r));
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── the importer ─────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
function provisioningFile(type: string, spec: Json, warnings: string[]): { file: Json; secretName?: string } {
|
|
156
|
+
const where = `${type.split("::").pop()}`;
|
|
157
|
+
const name = spec.name;
|
|
158
|
+
switch (type) {
|
|
159
|
+
case "K8s::Grafana::GrafanaAlertRuleGroup": {
|
|
160
|
+
if (typeof name !== "string") throw new Error(`${where} has no spec.name`);
|
|
161
|
+
if (!Array.isArray(spec.rules)) throw new Error(`${where} has no spec.rules`);
|
|
162
|
+
const folder = typeof spec.folderRef === "string" ? spec.folderRef : typeof spec.folderUID === "string" ? spec.folderUID : undefined;
|
|
163
|
+
if (folder === undefined) throw new Error(`${where} names no folder (folderRef or folderUID)`);
|
|
164
|
+
warnings.push(
|
|
165
|
+
`rule group "${name}": a declaration's folder is a title, and the resource holds only ${typeof spec.folderRef === "string" ? `the GrafanaFolder name "${folder}" (folderRef)` : `the folder uid "${folder}" (folderUID)`}, so folder is set to that; change it to the folder's title`,
|
|
166
|
+
);
|
|
167
|
+
const group: Json = { name, folder, ...(typeof spec.interval === "string" ? { interval: spec.interval } : {}), rules: spec.rules.map((r) => (isObject(r) ? provisionedRule(r) : r)) };
|
|
168
|
+
return { file: { apiVersion: 1, groups: [group] } };
|
|
169
|
+
}
|
|
170
|
+
case "K8s::Grafana::GrafanaContactPoint": {
|
|
171
|
+
if (typeof name !== "string") throw new Error(`${where} has no spec.name`);
|
|
172
|
+
if (!Array.isArray(spec.receivers)) throw new Error(`${where} has no spec.receivers`);
|
|
173
|
+
const { receivers, secretName } = provisionedReceivers(spec.receivers, `contact point "${name}"`);
|
|
174
|
+
return { file: { apiVersion: 1, contactPoints: [{ name, receivers }] }, secretName };
|
|
175
|
+
}
|
|
176
|
+
case "K8s::Grafana::GrafanaNotificationPolicy": {
|
|
177
|
+
if (!isObject(spec.route)) throw new Error(`${where} has no spec.route`);
|
|
178
|
+
// The routes it selects are their own resources, and a declaration cannot hold the selector: keep it as written.
|
|
179
|
+
if (spec.route.routeSelector !== undefined) throw new Error(`${where} merges routes in with spec.route.routeSelector, which a NotificationPolicy cannot hold`);
|
|
180
|
+
return { file: { apiVersion: 1, policies: [provisionedRoute(spec.route)] } };
|
|
181
|
+
}
|
|
182
|
+
case "K8s::Grafana::GrafanaNotificationPolicyRoute": {
|
|
183
|
+
// The k8s parser offers the whole spec as `route`. A route has no declaration of its own, so it is read as the one child of a policy with its receiver.
|
|
184
|
+
if (!isObject(spec.route) || typeof spec.route.receiver !== "string") throw new Error(`${where} has no spec.receiver`);
|
|
185
|
+
const { routeSelector: _selector, ...route } = spec.route;
|
|
186
|
+
if (_selector !== undefined) warnings.push("the route's routeSelector (routes merged in by label) is not part of a declaration, so it is left out; import the selected routes on their own");
|
|
187
|
+
return { file: { apiVersion: 1, policies: [{ receiver: route.receiver, routes: [provisionedRoute(route)] }] } };
|
|
188
|
+
}
|
|
189
|
+
case "K8s::Grafana::GrafanaMuteTiming": {
|
|
190
|
+
if (typeof name !== "string") throw new Error(`${where} has no spec.name`);
|
|
191
|
+
return { file: { apiVersion: 1, muteTimes: [{ name, time_intervals: spec.time_intervals }] } };
|
|
192
|
+
}
|
|
193
|
+
default: {
|
|
194
|
+
if (typeof name !== "string" || typeof spec.template !== "string") throw new Error(`${where} has no spec.name or spec.template`);
|
|
195
|
+
return { file: { apiVersion: 1, templates: [{ name, template: spec.template }] } };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const operatorImporter: EmbeddedContentImporter = {
|
|
201
|
+
what: "Grafana Operator alerting",
|
|
202
|
+
|
|
203
|
+
matches(content: EmbeddedContent) {
|
|
204
|
+
const kind = KINDS[content.hostType];
|
|
205
|
+
return kind !== undefined && content.select === kind.select && isObject(content.document);
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
import(content: EmbeddedContent): EmbeddedImport {
|
|
209
|
+
const kind = KINDS[content.hostType];
|
|
210
|
+
const warnings: string[] = [];
|
|
211
|
+
const { file, secretName } = provisioningFile(content.hostType, content.document as Json, warnings);
|
|
212
|
+
const planned = planAlertingProvisioning(file);
|
|
213
|
+
warnings.push(...planned.warnings);
|
|
214
|
+
|
|
215
|
+
const declaration = planned.plan.declarations.find((d) => d.className === kind.className);
|
|
216
|
+
const { files, exported } = generatePlanModules(planned.plan);
|
|
217
|
+
const binding = declaration ? exported.get(declaration.id) : undefined;
|
|
218
|
+
if (!binding) throw new Error(`no ${kind.className} was read from ${content.location}: ${planned.warnings.join(" ") || "the alerting importer dropped it"}`);
|
|
219
|
+
|
|
220
|
+
if (secretName === undefined) {
|
|
221
|
+
return {
|
|
222
|
+
files,
|
|
223
|
+
value: { bindings: [{ from: binding.path, name: binding.name }], shape: "single", through: { from: K8S_ENTRY, name: kind.through } },
|
|
224
|
+
warnings,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
// A declaration does not know the Secret its settings were read from, so a module of its own names it.
|
|
228
|
+
const module = {
|
|
229
|
+
path: "receivers.ts",
|
|
230
|
+
content: [
|
|
231
|
+
"/** The receivers of the contact point, with its secrets read from the Secret they were in. */",
|
|
232
|
+
`import { operatorReceivers } from "${K8S_ENTRY}";`,
|
|
233
|
+
`import { ${binding.name} } from "./${binding.path.replace(/\.ts$/, "")}";`,
|
|
234
|
+
"",
|
|
235
|
+
`const receivers = operatorReceivers(${binding.name}, ${JSON.stringify(secretName)});`,
|
|
236
|
+
"",
|
|
237
|
+
"export { receivers };",
|
|
238
|
+
"",
|
|
239
|
+
].join("\n"),
|
|
240
|
+
};
|
|
241
|
+
return { files: [...files, module], value: { bindings: [{ from: module.path, name: "receivers" }], shape: "single" }, warnings };
|
|
242
|
+
},
|
|
243
|
+
};
|