@intentius/chant-lexicon-prometheus 0.106.4 → 0.108.0

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.
Files changed (111) hide show
  1. package/README.md +6 -1
  2. package/dist/api/alertmanager.d.ts +56 -0
  3. package/dist/api/alertmanager.d.ts.map +1 -0
  4. package/dist/api/apply.d.ts +126 -0
  5. package/dist/api/apply.d.ts.map +1 -0
  6. package/dist/api/bind.d.ts +49 -0
  7. package/dist/api/bind.d.ts.map +1 -0
  8. package/dist/api/client.d.ts +76 -0
  9. package/dist/api/client.d.ts.map +1 -0
  10. package/dist/api/evaluated.d.ts +36 -0
  11. package/dist/api/evaluated.d.ts.map +1 -0
  12. package/dist/api/fake-servers.d.ts +74 -0
  13. package/dist/api/fake-servers.d.ts.map +1 -0
  14. package/dist/api/index.d.ts +18 -0
  15. package/dist/api/index.d.ts.map +1 -0
  16. package/dist/api/ruler.d.ts +87 -0
  17. package/dist/api/ruler.d.ts.map +1 -0
  18. package/dist/composites/alert-routing.d.ts +104 -0
  19. package/dist/composites/alert-routing.d.ts.map +1 -0
  20. package/dist/composites/catalog.d.ts.map +1 -1
  21. package/dist/composites/index.d.ts +6 -0
  22. package/dist/composites/index.d.ts.map +1 -1
  23. package/dist/composites/red-alerts.d.ts +96 -0
  24. package/dist/composites/red-alerts.d.ts.map +1 -0
  25. package/dist/composites/watchdog.d.ts +69 -0
  26. package/dist/composites/watchdog.d.ts.map +1 -0
  27. package/dist/config.d.ts +273 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/describe-resources.d.ts +48 -0
  30. package/dist/describe-resources.d.ts.map +1 -0
  31. package/dist/export-resources.d.ts +41 -0
  32. package/dist/export-resources.d.ts.map +1 -0
  33. package/dist/import/alertmanager-live.d.ts +35 -0
  34. package/dist/import/alertmanager-live.d.ts.map +1 -0
  35. package/dist/import/parser.d.ts +12 -0
  36. package/dist/import/parser.d.ts.map +1 -1
  37. package/dist/index.d.ts +6 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/integrity.json +3 -3
  40. package/dist/lint/post-synth/prom-helpers.d.ts +11 -3
  41. package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -1
  42. package/dist/manifest.json +1 -1
  43. package/dist/op/activities/index.d.ts +25 -0
  44. package/dist/op/activities/index.d.ts.map +1 -0
  45. package/dist/op/activities/promtool.d.ts +78 -0
  46. package/dist/op/activities/promtool.d.ts.map +1 -0
  47. package/dist/op/activities/rule-audit.d.ts +65 -0
  48. package/dist/op/activities/rule-audit.d.ts.map +1 -0
  49. package/dist/op/activities/rules-loaded.d.ts +60 -0
  50. package/dist/op/activities/rules-loaded.d.ts.map +1 -0
  51. package/dist/op/activities/silence.d.ts +80 -0
  52. package/dist/op/activities/silence.d.ts.map +1 -0
  53. package/dist/op/activity-contracts.d.ts +113 -0
  54. package/dist/op/activity-contracts.d.ts.map +1 -0
  55. package/dist/op/audit-op.d.ts +38 -0
  56. package/dist/op/audit-op.d.ts.map +1 -0
  57. package/dist/op/builders.d.ts +57 -0
  58. package/dist/op/builders.d.ts.map +1 -0
  59. package/dist/op/slo-rule-tests.d.ts +44 -0
  60. package/dist/op/slo-rule-tests.d.ts.map +1 -0
  61. package/dist/plugin.d.ts.map +1 -1
  62. package/dist/rules/prom-helpers.ts +38 -3
  63. package/package.json +19 -3
  64. package/src/api/alertmanager.ts +122 -0
  65. package/src/api/apply.test.ts +218 -0
  66. package/src/api/apply.ts +375 -0
  67. package/src/api/bind.ts +87 -0
  68. package/src/api/client.ts +138 -0
  69. package/src/api/evaluated.test.ts +78 -0
  70. package/src/api/evaluated.ts +106 -0
  71. package/src/api/fake-servers.ts +209 -0
  72. package/src/api/index.ts +29 -0
  73. package/src/api/ruler.ts +168 -0
  74. package/src/api/testdata/am-status-original.yml +85 -0
  75. package/src/composites/alert-routing.test.ts +163 -0
  76. package/src/composites/alert-routing.ts +277 -0
  77. package/src/composites/catalog.test.ts +10 -2
  78. package/src/composites/catalog.ts +214 -0
  79. package/src/composites/index.ts +14 -0
  80. package/src/composites/red-alerts.test.ts +182 -0
  81. package/src/composites/red-alerts.ts +214 -0
  82. package/src/composites/watchdog.test.ts +85 -0
  83. package/src/composites/watchdog.ts +142 -0
  84. package/src/config.test.ts +93 -0
  85. package/src/config.ts +288 -0
  86. package/src/describe-resources.test.ts +325 -0
  87. package/src/describe-resources.ts +328 -0
  88. package/src/export-resources.test.ts +253 -0
  89. package/src/export-resources.ts +193 -0
  90. package/src/import/alertmanager-live.test.ts +66 -0
  91. package/src/import/alertmanager-live.ts +291 -0
  92. package/src/import/parser.ts +18 -1
  93. package/src/import/roundtrip.test.ts +1 -0
  94. package/src/index.ts +37 -0
  95. package/src/lint/post-synth/prom-helpers.ts +38 -3
  96. package/src/op/activities/index.ts +31 -0
  97. package/src/op/activities/promtool.test.ts +107 -0
  98. package/src/op/activities/promtool.ts +149 -0
  99. package/src/op/activities/rule-audit.test.ts +89 -0
  100. package/src/op/activities/rule-audit.ts +168 -0
  101. package/src/op/activities/rules-loaded.test.ts +61 -0
  102. package/src/op/activities/rules-loaded.ts +98 -0
  103. package/src/op/activities/silence.test.ts +107 -0
  104. package/src/op/activities/silence.ts +175 -0
  105. package/src/op/activity-contracts.test.ts +141 -0
  106. package/src/op/activity-contracts.ts +96 -0
  107. package/src/op/audit-op.ts +54 -0
  108. package/src/op/builders.ts +82 -0
  109. package/src/op/slo-rule-tests.test.ts +84 -0
  110. package/src/op/slo-rule-tests.ts +173 -0
  111. package/src/plugin.ts +16 -0
@@ -0,0 +1,218 @@
1
+ /** The ruler apply target against a fake ruler (#3372). */
2
+
3
+ import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { describe, expect, test } from "vitest";
7
+ import { dump } from "js-yaml";
8
+ import { planRuler, rulerApply, rulerRollback, toApplyResult } from "./apply";
9
+ import { fakeAlertmanager, fakeHttp, fakeRuler, serveFake, type FakeAlertmanagerState, type FakeRulerState } from "./fake-servers";
10
+ import type { RawRuleGroup } from "./ruler";
11
+
12
+ const group = (name: string, expr = "up == 0"): RawRuleGroup => ({ name, rules: [{ alert: `${name}Down`, expr, for: "5m" }] });
13
+
14
+ function build(groups: RawRuleGroup[], alertmanager?: string): string {
15
+ const dir = mkdtempSync(join(tmpdir(), "chant-ruler-"));
16
+ const path = join(dir, "rules.yml");
17
+ writeFileSync(path, dump({ groups }));
18
+ if (alertmanager !== undefined) writeFileSync(join(dir, "alertmanager.yml"), alertmanager);
19
+ return path;
20
+ }
21
+
22
+ function setup(
23
+ rulerState: FakeRulerState,
24
+ opts: { namespace?: string; groupNamespaces?: Record<string, string>; am?: FakeAlertmanagerState } = {},
25
+ ) {
26
+ const rulerCalls: string[] = [];
27
+ const amCalls: string[] = [];
28
+ const kind = rulerState.kind as "mimir" | "cortex" | "loki";
29
+ const deps = {
30
+ config: {
31
+ prometheus: {
32
+ profiles: {
33
+ prod: {
34
+ ruler: { kind, url: "http://ruler.test", tenant: rulerState.tenant, namespace: opts.namespace ?? "shop", ...(opts.groupNamespaces ? { groupNamespaces: opts.groupNamespaces } : {}) },
35
+ ...(opts.am ? { alertmanager: { kind: opts.am.kind as "mimir", url: "http://am.test", tenant: opts.am.tenant } } : {}),
36
+ },
37
+ },
38
+ },
39
+ },
40
+ environment: "prod",
41
+ http: {
42
+ ruler: fakeHttp(fakeRuler(rulerState, rulerCalls), rulerState.tenant ? { "x-scope-orgid": rulerState.tenant } : {}),
43
+ ...(opts.am ? { alertmanager: fakeHttp(fakeAlertmanager(opts.am, amCalls), opts.am.tenant ? { "x-scope-orgid": opts.am.tenant } : {}) } : {}),
44
+ },
45
+ };
46
+ return { deps, rulerCalls, amCalls };
47
+ }
48
+
49
+ describe("rulerApply plan and apply", () => {
50
+ test("plan diffs declared groups against the ruler's, per declared namespace", async () => {
51
+ const state: FakeRulerState = { kind: "mimir", namespaces: { shop: [group("a"), group("b", "up == 1")] } };
52
+ const { deps } = setup(state);
53
+ const entries = await planRuler({ buildPath: build([group("a"), group("b"), group("c")]), environment: "prod", prune: true }, deps);
54
+ expect(entries.map((e) => `${e.action} ${e.name}`).sort()).toEqual(["create shop/c", "unchanged shop/a", "update shop/b"]);
55
+ });
56
+
57
+ test("apply posts created and changed groups and leaves an unchanged one alone", async () => {
58
+ const state: FakeRulerState = { kind: "mimir", namespaces: { shop: [group("a"), group("b", "up == 1")] } };
59
+ const { deps, rulerCalls } = setup(state);
60
+ const outcome = await rulerApply({ buildPath: build([group("a"), group("b"), group("c")]), environment: "prod" }, deps);
61
+ expect(outcome.applied.map((a) => `${a.action} ${a.name}`).sort()).toEqual(["created shop/c", "unchanged shop/a", "updated shop/b"]);
62
+ expect(rulerCalls.filter((c) => c.startsWith("POST"))).toEqual([
63
+ "POST /prometheus/config/v1/rules/shop",
64
+ "POST /prometheus/config/v1/rules/shop",
65
+ ]);
66
+ expect(state.namespaces.shop.map((g) => g.name).sort()).toEqual(["a", "b", "c"]);
67
+ expect(toApplyResult(outcome).applied).toHaveLength(3);
68
+ });
69
+
70
+ test("a group the ruler decorates with fields of its own is unchanged", async () => {
71
+ const state: FakeRulerState = { kind: "mimir", namespaces: { shop: [{ ...group("a"), source_tenants: ["shop"] }] } };
72
+ const { deps, rulerCalls } = setup(state);
73
+ const outcome = await rulerApply({ buildPath: build([group("a")]), environment: "prod" }, deps);
74
+ expect(outcome.applied).toEqual([{ kind: "RuleGroup", name: "shop/a", action: "unchanged" }]);
75
+ expect(rulerCalls.some((c) => c.startsWith("POST"))).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe("owned-only delete", () => {
80
+ test("removes undeclared groups in a declared namespace and never touches a namespace the project does not declare", async () => {
81
+ const other = [group("theirs"), group("theirs-too")];
82
+ const state: FakeRulerState = {
83
+ kind: "mimir",
84
+ namespaces: { shop: [group("a"), group("stale")], "platform-team": other, "moved-away": [group("a")] },
85
+ };
86
+ const { deps, rulerCalls } = setup(state);
87
+ const outcome = await rulerApply({ buildPath: build([group("a")]), environment: "prod", prune: true }, deps);
88
+
89
+ expect(outcome.pruned).toEqual([{ kind: "RuleGroup", name: "shop/stale", deleted: true }]);
90
+ expect(state.namespaces.shop.map((g) => g.name)).toEqual(["a"]);
91
+ // The namespaces the project does not declare are intact, and no request ever named them.
92
+ expect(state.namespaces["platform-team"]).toEqual(other);
93
+ expect(state.namespaces["moved-away"]).toEqual([group("a")]);
94
+ expect(rulerCalls.filter((c) => c.includes("platform-team") || c.includes("moved-away"))).toEqual([]);
95
+ // Nor was the whole tenant listed: that is how a sync would find namespaces to delete.
96
+ expect(rulerCalls).not.toContain("GET /prometheus/config/v1/rules");
97
+ expect(rulerCalls.filter((c) => c.startsWith("DELETE"))).toEqual(["DELETE /prometheus/config/v1/rules/shop/stale"]);
98
+ });
99
+
100
+ test("without prune nothing is deleted", async () => {
101
+ const state: FakeRulerState = { kind: "mimir", namespaces: { shop: [group("a"), group("stale")] } };
102
+ const { deps, rulerCalls } = setup(state);
103
+ await rulerApply({ buildPath: build([group("a")]), environment: "prod" }, deps);
104
+ expect(rulerCalls.some((c) => c.startsWith("DELETE"))).toBe(false);
105
+ expect(state.namespaces.shop).toHaveLength(2);
106
+ });
107
+
108
+ test("a group routed by groupNamespaces lands in its own namespace, and both are owned", async () => {
109
+ const state: FakeRulerState = { kind: "mimir", namespaces: { shop: [group("stale")], infra: [group("old")], elsewhere: [group("x")] } };
110
+ const { deps } = setup(state, { groupNamespaces: { disk: "infra" } });
111
+ await rulerApply({ buildPath: build([group("a"), group("disk")]), environment: "prod", prune: true }, deps);
112
+ expect(state.namespaces.shop.map((g) => g.name)).toEqual(["a"]);
113
+ expect(state.namespaces.infra.map((g) => g.name)).toEqual(["disk"]);
114
+ expect(state.namespaces.elsewhere).toEqual([group("x")]);
115
+ });
116
+ });
117
+
118
+ describe("tenants and path variants", () => {
119
+ test("X-Scope-OrgID is the environment's tenant, over a real HTTP server", async () => {
120
+ const state: FakeRulerState = { kind: "mimir", tenant: "shop", namespaces: {} };
121
+ const server = await serveFake(fakeRuler(state));
122
+ try {
123
+ const deps = {
124
+ config: { prometheus: { profiles: { prod: { ruler: { kind: "mimir" as const, url: server.url, tenant: "shop", namespace: "shop" } } } } },
125
+ environment: "prod",
126
+ };
127
+ const outcome = await rulerApply({ buildPath: build([group("a")]), environment: "prod" }, deps);
128
+ expect(outcome.applied).toEqual([{ kind: "RuleGroup", name: "shop/a", action: "created" }]);
129
+ expect(state.namespaces.shop).toHaveLength(1);
130
+ } finally {
131
+ await server.close();
132
+ }
133
+ });
134
+
135
+ test("a wrong tenant is refused, and reported rather than thrown", async () => {
136
+ const state: FakeRulerState = { kind: "mimir", tenant: "other", namespaces: {} };
137
+ const { deps } = setup({ ...state, tenant: "other" });
138
+ const refused = { ...deps, http: { ruler: fakeHttp(fakeRuler(state), { "x-scope-orgid": "wrong" }) } };
139
+ const outcome = await rulerApply({ buildPath: build([group("a")]), environment: "prod" }, refused);
140
+ expect(outcome.applied).toEqual([]);
141
+ expect(outcome.notAttempted[0].reason).toBe("no-credentials");
142
+ });
143
+
144
+ test.each([
145
+ ["cortex", "/api/v1/rules/shop"],
146
+ ["loki", "/loki/api/v1/rules/shop"],
147
+ ["mimir", "/prometheus/config/v1/rules/shop"],
148
+ ] as const)("%s posts to %s", async (kind, path) => {
149
+ const state: FakeRulerState = { kind, namespaces: {} };
150
+ const { deps, rulerCalls } = setup(state);
151
+ await rulerApply({ buildPath: build([group("a")]), environment: "prod" }, deps);
152
+ expect(rulerCalls).toContain(`POST ${path}`);
153
+ });
154
+ });
155
+
156
+ describe("Alertmanager config upload", () => {
157
+ const amYaml = "route:\n receiver: default\nreceivers:\n - name: default\n";
158
+
159
+ test("uploads the config beside the rule file under the same target", async () => {
160
+ const am: FakeAlertmanagerState = { kind: "mimir", tenant: "shop" };
161
+ const { deps } = setup({ kind: "mimir", tenant: "shop", namespaces: {} }, { am });
162
+ const outcome = await rulerApply({ buildPath: build([group("a")], amYaml), environment: "prod" }, deps);
163
+ expect(outcome.applied.map((a) => `${a.action} ${a.kind}`).sort()).toEqual(["created AlertmanagerConfig", "created RuleGroup"]);
164
+ expect(am.uploaded?.alertmanager_config).toBe(amYaml);
165
+ });
166
+
167
+ test("an unchanged config is not uploaded again", async () => {
168
+ const am: FakeAlertmanagerState = { kind: "mimir", uploaded: { alertmanager_config: amYaml } };
169
+ const { deps, amCalls } = setup({ kind: "mimir", namespaces: {} }, { am });
170
+ const outcome = await rulerApply({ buildPath: build([group("a")], amYaml), environment: "prod" }, deps);
171
+ expect(outcome.applied.find((a) => a.kind === "AlertmanagerConfig")?.action).toBe("unchanged");
172
+ expect(amCalls.some((c) => c.startsWith("POST"))).toBe(false);
173
+ });
174
+
175
+ test("a plain Alertmanager has no upload API and is reported not attempted", async () => {
176
+ const am: FakeAlertmanagerState = { kind: "alertmanager", original: amYaml };
177
+ const { deps } = setup({ kind: "mimir", namespaces: {} }, { am });
178
+ const outcome = await rulerApply({ buildPath: build([group("a")], amYaml), environment: "prod" }, deps);
179
+ expect(outcome.notAttempted).toMatchObject([{ kind: "AlertmanagerConfig", reason: "unsupported-kind" }]);
180
+ });
181
+ });
182
+
183
+ describe("rollback re-applies the previous group set", () => {
184
+ test("restores replaced and pruned groups, removes created ones, and leaves other namespaces alone", async () => {
185
+ const other = [group("theirs")];
186
+ const state: FakeRulerState = { kind: "mimir", namespaces: { shop: [group("b", "up == 1"), group("stale")], other } };
187
+ const { deps, rulerCalls } = setup(state);
188
+ const path = build([group("b"), group("c")]);
189
+ await rulerApply({ buildPath: path, environment: "prod", prune: true }, deps);
190
+ expect(state.namespaces.shop.map((g) => g.name).sort()).toEqual(["b", "c"]);
191
+
192
+ const outcome = await rulerRollback({ buildPath: path, environment: "prod" }, deps);
193
+ expect(outcome).toMatchObject({ restored: 2, removed: 1, hadSnapshot: true });
194
+ expect(state.namespaces.shop.map((g) => g.name).sort()).toEqual(["b", "stale"]);
195
+ expect(state.namespaces.shop.find((g) => g.name === "b")).toMatchObject({ rules: [{ expr: "up == 1" }] });
196
+ expect(state.namespaces.other).toEqual(other);
197
+ expect(rulerCalls.filter((c) => c.includes("/other"))).toEqual([]);
198
+ });
199
+
200
+ test("restores the previous Alertmanager config", async () => {
201
+ const before = "route:\n receiver: old\nreceivers:\n - name: old\n";
202
+ const am: FakeAlertmanagerState = { kind: "mimir", uploaded: { alertmanager_config: before } };
203
+ const { deps } = setup({ kind: "mimir", namespaces: {} }, { am });
204
+ const path = build([group("a")], "route:\n receiver: new\nreceivers:\n - name: new\n");
205
+ await rulerApply({ buildPath: path, environment: "prod" }, deps);
206
+ expect(am.uploaded?.alertmanager_config).toContain("new");
207
+ const outcome = await rulerRollback({ buildPath: path, environment: "prod" }, deps);
208
+ expect(outcome.alertmanagerRestored).toBe(true);
209
+ expect(am.uploaded?.alertmanager_config).toBe(before);
210
+ });
211
+
212
+ test("with no snapshot there is nothing to restore", async () => {
213
+ const { deps } = setup({ kind: "mimir", namespaces: {} });
214
+ const path = build([group("a")]);
215
+ expect((await rulerRollback({ buildPath: path, environment: "prod" }, deps)).hadSnapshot).toBe(false);
216
+ expect(existsSync(path)).toBe(true);
217
+ });
218
+ });
@@ -0,0 +1,375 @@
1
+ /**
2
+ * The ruler apply target (#3372): `nativeApply`'s `ruler` target, reached
3
+ * through `@intentius/chant-lexicon-prometheus/api/apply` the way core
4
+ * reaches the grafana and sql appliers. Core imports no lexicon; it loads
5
+ * this module by name at call time.
6
+ *
7
+ * One transport with observe and export ({@link bindEndpoints}), so the
8
+ * tenant (`X-Scope-OrgID`) and the Mimir, Cortex and Loki path variants are
9
+ * the profile's: `prometheus.profiles.<env>.ruler`.
10
+ *
11
+ * The ownership boundary is {@link declaredNamespaces}. The plan reads only
12
+ * those namespaces, a group is written only into the namespace
13
+ * {@link namespaceOfGroup} gives it, and an owned-only delete removes a
14
+ * group the ruler has and the project does not declare, one group at a
15
+ * time, only inside those namespaces. A namespace the project does not
16
+ * declare is never listed, read, written or deleted. This is the difference
17
+ * from `mimirtool rules sync`, which deletes every remote namespace missing
18
+ * from the local files unless `--namespaces` is set.
19
+ *
20
+ * Rollback re-applies the previous group set. Before the first write an
21
+ * apply saves, beside the build file, what each changed or pruned group
22
+ * looked like (and which groups it creates); {@link rulerRollback} puts the
23
+ * first back and deletes the second.
24
+ */
25
+
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { basename, dirname, join } from "node:path";
28
+ import {
29
+ applyResult,
30
+ type AppliedAction,
31
+ type ApplyResult,
32
+ type NotAttemptedReason,
33
+ } from "@intentius/chant/apply";
34
+ import { loadPrometheusYaml } from "../import/parser";
35
+ import { declaredNamespaces, isUnresolvedTarget, namespaceOfGroup, type RulerTarget } from "../config";
36
+ import { bindEndpoints, classifyPromFailure, type BindOptions } from "./bind";
37
+ import { PromApiError } from "./client";
38
+ import type { RawRuleGroup, RulerApi } from "./ruler";
39
+ import type { AlertmanagerApi } from "./alertmanager";
40
+
41
+ type Json = Record<string, unknown>;
42
+
43
+ export interface RulerApplyArgs {
44
+ /** The build's primary file: the rule file (`groups:`), or an `alertmanager.yml` when no rules are declared. */
45
+ buildPath: string;
46
+ /** The chant environment whose `prometheus.profiles.<env>` names the ruler. */
47
+ environment?: string;
48
+ /** Delete groups the ruler has in a declared namespace and the project does not declare. */
49
+ prune?: boolean;
50
+ }
51
+
52
+ export interface RulerApplyDeps extends BindOptions {}
53
+
54
+ export interface RulerPlanEntry {
55
+ kind: "RuleGroup" | "AlertmanagerConfig";
56
+ /** `<namespace>/<group>`, or the Alertmanager tenant. */
57
+ name: string;
58
+ namespace?: string;
59
+ group?: string;
60
+ action: "create" | "update" | "unchanged" | "delete";
61
+ }
62
+
63
+ export interface RulerOutcome {
64
+ applied: Array<{ kind: string; name: string; action: AppliedAction }>;
65
+ pruned: Array<{ kind: string; name: string; deleted: boolean }>;
66
+ notAttempted: Array<{ kind: string; name: string; reason: NotAttemptedReason; detail?: string }>;
67
+ }
68
+
69
+ /** What a rollback needs: the previous form of what an apply wrote, and what it created. */
70
+ export interface RulerSnapshot {
71
+ environment: string;
72
+ /** The previous form of every group the apply replaced or deleted. */
73
+ restore: Array<{ namespace: string; group: RawRuleGroup }>;
74
+ /** Groups the apply created. */
75
+ remove: Array<{ namespace: string; name: string }>;
76
+ /** The Alertmanager config before an upload, when there was one. */
77
+ alertmanager?: { text: string; templateFiles?: Record<string, string> };
78
+ }
79
+
80
+ interface Built {
81
+ groups: Array<Json & { name: string }>;
82
+ alertmanager?: string;
83
+ }
84
+
85
+ function isObject(v: unknown): v is Json {
86
+ return typeof v === "object" && v !== null && !Array.isArray(v);
87
+ }
88
+
89
+ /** Read the build: the rule groups, and an Alertmanager config (the primary itself, or `alertmanager.yml` beside it). */
90
+ export function readBuilt(buildPath: string): Built {
91
+ const text = readFileSync(buildPath, "utf8");
92
+ const doc = loadPrometheusYaml(text);
93
+ const out: Built = { groups: [] };
94
+ if (isObject(doc) && Array.isArray(doc.groups)) {
95
+ out.groups = doc.groups.filter((g): g is Json & { name: string } => isObject(g) && typeof g.name === "string");
96
+ } else if (isObject(doc) && ("route" in doc || "receivers" in doc)) {
97
+ out.alertmanager = text;
98
+ }
99
+ const sibling = join(dirname(buildPath), "alertmanager.yml");
100
+ if (out.alertmanager === undefined && basename(buildPath) !== "alertmanager.yml" && existsSync(sibling)) {
101
+ out.alertmanager = readFileSync(sibling, "utf8");
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * True when every key the declared value carries is in the live one with an
108
+ * equal value. A ruler adds fields of its own to a group it returns
109
+ * (`source_tenants`, say), and those are not a difference.
110
+ */
111
+ export function covers(declared: unknown, live: unknown): boolean {
112
+ if (Array.isArray(declared)) {
113
+ return Array.isArray(live) && declared.length === live.length && declared.every((d, i) => covers(d, live[i]));
114
+ }
115
+ if (isObject(declared)) {
116
+ return isObject(live) && Object.entries(declared).every(([k, v]) => covers(v, live[k]));
117
+ }
118
+ return declared === live;
119
+ }
120
+
121
+ /** The snapshot file an apply to `environment` writes beside the build file. */
122
+ export function snapshotPath(buildPath: string, environment: string): string {
123
+ return join(dirname(buildPath), `.chant-ruler-previous.${environment.replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
124
+ }
125
+
126
+ interface Plan {
127
+ entries: RulerPlanEntry[];
128
+ /** The declared groups to write, with their namespace. */
129
+ writes: Array<{ namespace: string; group: Json & { name: string }; action: "create" | "update"; previous?: RawRuleGroup }>;
130
+ deletes: Array<{ namespace: string; group: RawRuleGroup }>;
131
+ notAttempted: RulerOutcome["notAttempted"];
132
+ }
133
+
134
+ async function planGroups(ruler: RulerApi, built: Built, prune: boolean): Promise<Plan> {
135
+ const target: RulerTarget = ruler.target;
136
+ const plan: Plan = { entries: [], writes: [], deletes: [], notAttempted: [] };
137
+ const declaredNs = declaredNamespaces(target);
138
+ // Read only the declared namespaces. A namespace the project does not
139
+ // declare is never listed, so nothing below can reach it.
140
+ const live = new Map<string, RawRuleGroup[]>();
141
+ for (const ns of declaredNs) live.set(ns, (await ruler.readNamespace(ns)) ?? []);
142
+
143
+ const declaredByNs = new Map<string, Set<string>>();
144
+ for (const group of built.groups) {
145
+ const ns = namespaceOfGroup(target, group.name);
146
+ if (ns === undefined || !declaredNs.includes(ns)) {
147
+ plan.notAttempted.push({
148
+ kind: "RuleGroup",
149
+ name: group.name,
150
+ reason: "no-binding",
151
+ detail: `no ruler namespace is declared for group "${group.name}": set prometheus.profiles.<env>.ruler.namespace or groupNamespaces`,
152
+ });
153
+ continue;
154
+ }
155
+ (declaredByNs.get(ns) ?? declaredByNs.set(ns, new Set()).get(ns)!).add(group.name);
156
+ const previous = live.get(ns)?.find((g) => g.name === group.name);
157
+ const name = `${ns}/${group.name}`;
158
+ if (previous === undefined) {
159
+ plan.writes.push({ namespace: ns, group, action: "create" });
160
+ plan.entries.push({ kind: "RuleGroup", name, namespace: ns, group: group.name, action: "create" });
161
+ } else if (!covers(group, previous)) {
162
+ plan.writes.push({ namespace: ns, group, action: "update", previous });
163
+ plan.entries.push({ kind: "RuleGroup", name, namespace: ns, group: group.name, action: "update" });
164
+ } else {
165
+ plan.entries.push({ kind: "RuleGroup", name, namespace: ns, group: group.name, action: "unchanged" });
166
+ }
167
+ }
168
+ if (prune) {
169
+ for (const ns of declaredNs) {
170
+ for (const g of live.get(ns) ?? []) {
171
+ if (declaredByNs.get(ns)?.has(g.name)) continue;
172
+ plan.deletes.push({ namespace: ns, group: g });
173
+ plan.entries.push({ kind: "RuleGroup", name: `${ns}/${g.name}`, namespace: ns, group: g.name, action: "delete" });
174
+ }
175
+ }
176
+ }
177
+ return plan;
178
+ }
179
+
180
+ async function planAlertmanager(
181
+ am: AlertmanagerApi,
182
+ declared: string,
183
+ ): Promise<{ entry: RulerPlanEntry; previous?: { text: string; templateFiles?: Record<string, string> } }> {
184
+ const name = am.target.tenant ?? "alertmanager";
185
+ const current = await am.readConfig();
186
+ if (current === undefined) return { entry: { kind: "AlertmanagerConfig", name, action: "create" } };
187
+ const same = JSON.stringify(loadPrometheusYaml(current.text)) === JSON.stringify(loadPrometheusYaml(declared));
188
+ return {
189
+ entry: { kind: "AlertmanagerConfig", name, action: same ? "unchanged" : "update" },
190
+ previous: { text: current.text, ...(current.templateFiles ? { templateFiles: current.templateFiles } : {}) },
191
+ };
192
+ }
193
+
194
+ /** What an apply would do, per declared namespace. Reads the ruler; writes nothing. */
195
+ export async function planRuler(args: RulerApplyArgs, deps: RulerApplyDeps = {}): Promise<RulerPlanEntry[]> {
196
+ const bound = await bindEndpoints({ ...deps, environment: args.environment });
197
+ if (isUnresolvedTarget(bound.ruler)) throw new Error(bound.ruler.detail);
198
+ const built = readBuilt(args.buildPath);
199
+ const plan = await planGroups(bound.ruler, built, args.prune ?? false);
200
+ const entries = [...plan.entries];
201
+ if (built.alertmanager !== undefined && !isUnresolvedTarget(bound.alertmanager) && bound.alertmanager.target.kind !== "alertmanager") {
202
+ entries.push((await planAlertmanager(bound.alertmanager, built.alertmanager)).entry);
203
+ }
204
+ return entries;
205
+ }
206
+
207
+ function allNotAttempted(built: Built, reason: NotAttemptedReason, detail: string): RulerOutcome {
208
+ return {
209
+ applied: [],
210
+ pruned: [],
211
+ notAttempted: [
212
+ ...built.groups.map((g) => ({ kind: "RuleGroup", name: g.name, reason, detail })),
213
+ ...(built.alertmanager !== undefined ? [{ kind: "AlertmanagerConfig", name: "alertmanager", reason, detail }] : []),
214
+ ],
215
+ };
216
+ }
217
+
218
+ /** Apply the build to the environment's ruler (and Alertmanager). */
219
+ export async function rulerApply(args: RulerApplyArgs, deps: RulerApplyDeps = {}): Promise<RulerOutcome> {
220
+ const environment = args.environment ?? "default";
221
+ const built = readBuilt(args.buildPath);
222
+ const bound = await bindEndpoints({ ...deps, environment: args.environment });
223
+ const out: RulerOutcome = { applied: [], pruned: [], notAttempted: [] };
224
+
225
+ let snapshot: RulerSnapshot = { environment, restore: [], remove: [] };
226
+ let writeSnapshot = false;
227
+
228
+ if (built.groups.length > 0 || args.prune) {
229
+ if (isUnresolvedTarget(bound.ruler)) {
230
+ const failed = allNotAttempted({ groups: built.groups }, bound.ruler.reason, bound.ruler.detail);
231
+ out.notAttempted.push(...failed.notAttempted);
232
+ } else if (bound.ruler.target.kind === "prometheus") {
233
+ const failed = allNotAttempted({ groups: built.groups }, "unsupported-kind", `a plain Prometheus (${bound.ruler.target.source}) has no rule group API`);
234
+ out.notAttempted.push(...failed.notAttempted);
235
+ } else {
236
+ const ruler = bound.ruler;
237
+ try {
238
+ const plan = await planGroups(ruler, built, args.prune ?? false);
239
+ out.notAttempted.push(...plan.notAttempted);
240
+ snapshot.restore = [
241
+ ...plan.writes.filter((w) => w.previous).map((w) => ({ namespace: w.namespace, group: w.previous! })),
242
+ ...plan.deletes.map((d) => ({ namespace: d.namespace, group: d.group })),
243
+ ];
244
+ snapshot.remove = plan.writes.filter((w) => w.action === "create").map((w) => ({ namespace: w.namespace, name: w.group.name }));
245
+ writeSnapshot = plan.writes.length > 0 || plan.deletes.length > 0;
246
+ if (writeSnapshot) saveSnapshot(args.buildPath, snapshot);
247
+
248
+ for (const e of plan.entries) {
249
+ if (e.action === "unchanged") out.applied.push({ kind: e.kind, name: e.name, action: "unchanged" });
250
+ }
251
+ for (const w of plan.writes) {
252
+ const name = `${w.namespace}/${w.group.name}`;
253
+ try {
254
+ await ruler.setGroup(w.namespace, w.group as unknown as RawRuleGroup);
255
+ out.applied.push({ kind: "RuleGroup", name, action: w.action === "create" ? "created" : "updated" });
256
+ } catch (err) {
257
+ out.notAttempted.push({ kind: "RuleGroup", name, ...failure(err) });
258
+ }
259
+ }
260
+ for (const d of plan.deletes) {
261
+ const name = `${d.namespace}/${d.group.name}`;
262
+ try {
263
+ await ruler.deleteGroup(d.namespace, d.group.name);
264
+ out.pruned.push({ kind: "RuleGroup", name, deleted: true });
265
+ } catch (err) {
266
+ if (err instanceof PromApiError && err.status === 404) out.pruned.push({ kind: "RuleGroup", name, deleted: false });
267
+ else out.notAttempted.push({ kind: "RuleGroup", name, ...failure(err) });
268
+ }
269
+ }
270
+ } catch (err) {
271
+ const f = failure(err);
272
+ out.notAttempted.push(...built.groups.map((g) => ({ kind: "RuleGroup", name: g.name, ...f })));
273
+ }
274
+ }
275
+ }
276
+
277
+ if (built.alertmanager !== undefined) {
278
+ const amName = isUnresolvedTarget(bound.alertmanager) ? "alertmanager" : (bound.alertmanager.target.tenant ?? "alertmanager");
279
+ if (isUnresolvedTarget(bound.alertmanager)) {
280
+ out.notAttempted.push({ kind: "AlertmanagerConfig", name: amName, reason: bound.alertmanager.reason, detail: bound.alertmanager.detail });
281
+ } else if (bound.alertmanager.target.kind === "alertmanager") {
282
+ out.notAttempted.push({
283
+ kind: "AlertmanagerConfig",
284
+ name: amName,
285
+ reason: "unsupported-kind",
286
+ detail: `a plain Alertmanager (${bound.alertmanager.target.source}) has no config API; only Mimir and Cortex take an upload`,
287
+ });
288
+ } else {
289
+ try {
290
+ const { entry, previous } = await planAlertmanager(bound.alertmanager, built.alertmanager);
291
+ if (entry.action === "unchanged") {
292
+ out.applied.push({ kind: entry.kind, name: entry.name, action: "unchanged" });
293
+ } else {
294
+ if (previous) {
295
+ snapshot = { ...snapshot, alertmanager: previous };
296
+ }
297
+ saveSnapshot(args.buildPath, snapshot);
298
+ await bound.alertmanager.setConfig(built.alertmanager);
299
+ out.applied.push({ kind: entry.kind, name: entry.name, action: entry.action === "create" ? "created" : "updated" });
300
+ }
301
+ } catch (err) {
302
+ out.notAttempted.push({ kind: "AlertmanagerConfig", name: amName, ...failure(err) });
303
+ }
304
+ }
305
+ }
306
+ return out;
307
+ }
308
+
309
+ function failure(err: unknown): { reason: NotAttemptedReason; detail: string } {
310
+ const { reason, detail } = classifyPromFailure(err);
311
+ return { reason: reason === "no-credentials" || reason === "no-binding" ? reason : "dependency-failed", detail };
312
+ }
313
+
314
+ function saveSnapshot(buildPath: string, snapshot: RulerSnapshot): void {
315
+ const path = snapshotPath(buildPath, snapshot.environment);
316
+ mkdirSync(dirname(path), { recursive: true });
317
+ writeFileSync(path, JSON.stringify(snapshot, null, 2));
318
+ }
319
+
320
+ export interface RulerRollbackOutcome {
321
+ /** Groups put back to their previous form. */
322
+ restored: number;
323
+ /** Groups the apply created, deleted again. */
324
+ removed: number;
325
+ alertmanagerRestored: boolean;
326
+ /** False when no apply to this environment left a snapshot. */
327
+ hadSnapshot: boolean;
328
+ }
329
+
330
+ /**
331
+ * Re-apply the previous group set: put back every group the last apply
332
+ * replaced or deleted, and delete the ones it created. Touches only the
333
+ * groups the snapshot names, so a namespace the project does not declare is
334
+ * as untouched here as in the apply.
335
+ */
336
+ export async function rulerRollback(args: { buildPath: string; environment?: string }, deps: RulerApplyDeps = {}): Promise<RulerRollbackOutcome> {
337
+ const environment = args.environment ?? "default";
338
+ const path = snapshotPath(args.buildPath, environment);
339
+ const outcome: RulerRollbackOutcome = { restored: 0, removed: 0, alertmanagerRestored: false, hadSnapshot: existsSync(path) };
340
+ if (!outcome.hadSnapshot) return outcome;
341
+ const snapshot = JSON.parse(readFileSync(path, "utf8")) as RulerSnapshot;
342
+ const bound = await bindEndpoints({ ...deps, environment: args.environment });
343
+ if (snapshot.restore.length > 0 || snapshot.remove.length > 0) {
344
+ if (isUnresolvedTarget(bound.ruler)) throw new Error(bound.ruler.detail);
345
+ const declared = declaredNamespaces(bound.ruler.target);
346
+ for (const r of snapshot.restore) {
347
+ if (!declared.includes(r.namespace)) continue;
348
+ await bound.ruler.setGroup(r.namespace, r.group);
349
+ outcome.restored++;
350
+ }
351
+ for (const r of snapshot.remove) {
352
+ if (!declared.includes(r.namespace)) continue;
353
+ try {
354
+ await bound.ruler.deleteGroup(r.namespace, r.name);
355
+ outcome.removed++;
356
+ } catch (err) {
357
+ if (!(err instanceof PromApiError && err.status === 404)) throw err;
358
+ }
359
+ }
360
+ }
361
+ if (snapshot.alertmanager !== undefined && !isUnresolvedTarget(bound.alertmanager) && bound.alertmanager.target.kind !== "alertmanager") {
362
+ await bound.alertmanager.setConfig(snapshot.alertmanager.text, snapshot.alertmanager.templateFiles ?? {});
363
+ outcome.alertmanagerRestored = true;
364
+ }
365
+ return outcome;
366
+ }
367
+
368
+ /** The outcome as core's apply envelope (#1446). */
369
+ export function toApplyResult(outcome: RulerOutcome): ApplyResult {
370
+ return applyResult(
371
+ outcome.applied.map((a) => ({ kind: a.kind, name: a.name, action: a.action })),
372
+ outcome.pruned,
373
+ outcome.notAttempted,
374
+ );
375
+ }