@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.
- package/README.md +6 -1
- package/dist/api/alertmanager.d.ts +56 -0
- package/dist/api/alertmanager.d.ts.map +1 -0
- package/dist/api/apply.d.ts +126 -0
- package/dist/api/apply.d.ts.map +1 -0
- package/dist/api/bind.d.ts +49 -0
- package/dist/api/bind.d.ts.map +1 -0
- package/dist/api/client.d.ts +76 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/evaluated.d.ts +36 -0
- package/dist/api/evaluated.d.ts.map +1 -0
- package/dist/api/fake-servers.d.ts +74 -0
- package/dist/api/fake-servers.d.ts.map +1 -0
- package/dist/api/index.d.ts +18 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/ruler.d.ts +87 -0
- package/dist/api/ruler.d.ts.map +1 -0
- package/dist/composites/alert-routing.d.ts +104 -0
- package/dist/composites/alert-routing.d.ts.map +1 -0
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/index.d.ts +6 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/red-alerts.d.ts +96 -0
- package/dist/composites/red-alerts.d.ts.map +1 -0
- package/dist/composites/watchdog.d.ts +69 -0
- package/dist/composites/watchdog.d.ts.map +1 -0
- package/dist/config.d.ts +273 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +48 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/export-resources.d.ts +41 -0
- package/dist/export-resources.d.ts.map +1 -0
- package/dist/import/alertmanager-live.d.ts +35 -0
- package/dist/import/alertmanager-live.d.ts.map +1 -0
- package/dist/import/parser.d.ts +12 -0
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/lint/post-synth/prom-helpers.d.ts +11 -3
- package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +25 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/op/activities/promtool.d.ts +78 -0
- package/dist/op/activities/promtool.d.ts.map +1 -0
- package/dist/op/activities/rule-audit.d.ts +65 -0
- package/dist/op/activities/rule-audit.d.ts.map +1 -0
- package/dist/op/activities/rules-loaded.d.ts +60 -0
- package/dist/op/activities/rules-loaded.d.ts.map +1 -0
- package/dist/op/activities/silence.d.ts +80 -0
- package/dist/op/activities/silence.d.ts.map +1 -0
- package/dist/op/activity-contracts.d.ts +113 -0
- package/dist/op/activity-contracts.d.ts.map +1 -0
- package/dist/op/audit-op.d.ts +38 -0
- package/dist/op/audit-op.d.ts.map +1 -0
- package/dist/op/builders.d.ts +57 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/op/slo-rule-tests.d.ts +44 -0
- package/dist/op/slo-rule-tests.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/prom-helpers.ts +38 -3
- package/package.json +19 -3
- package/src/api/alertmanager.ts +122 -0
- package/src/api/apply.test.ts +218 -0
- package/src/api/apply.ts +375 -0
- package/src/api/bind.ts +87 -0
- package/src/api/client.ts +138 -0
- package/src/api/evaluated.test.ts +78 -0
- package/src/api/evaluated.ts +106 -0
- package/src/api/fake-servers.ts +209 -0
- package/src/api/index.ts +29 -0
- package/src/api/ruler.ts +168 -0
- package/src/api/testdata/am-status-original.yml +85 -0
- package/src/composites/alert-routing.test.ts +163 -0
- package/src/composites/alert-routing.ts +277 -0
- package/src/composites/catalog.test.ts +10 -2
- package/src/composites/catalog.ts +214 -0
- package/src/composites/index.ts +14 -0
- package/src/composites/red-alerts.test.ts +182 -0
- package/src/composites/red-alerts.ts +214 -0
- package/src/composites/watchdog.test.ts +85 -0
- package/src/composites/watchdog.ts +142 -0
- package/src/config.test.ts +93 -0
- package/src/config.ts +288 -0
- package/src/describe-resources.test.ts +325 -0
- package/src/describe-resources.ts +328 -0
- package/src/export-resources.test.ts +253 -0
- package/src/export-resources.ts +193 -0
- package/src/import/alertmanager-live.test.ts +66 -0
- package/src/import/alertmanager-live.ts +291 -0
- package/src/import/parser.ts +18 -1
- package/src/import/roundtrip.test.ts +1 -0
- package/src/index.ts +37 -0
- package/src/lint/post-synth/prom-helpers.ts +38 -3
- package/src/op/activities/index.ts +31 -0
- package/src/op/activities/promtool.test.ts +107 -0
- package/src/op/activities/promtool.ts +149 -0
- package/src/op/activities/rule-audit.test.ts +89 -0
- package/src/op/activities/rule-audit.ts +168 -0
- package/src/op/activities/rules-loaded.test.ts +61 -0
- package/src/op/activities/rules-loaded.ts +98 -0
- package/src/op/activities/silence.test.ts +107 -0
- package/src/op/activities/silence.ts +175 -0
- package/src/op/activity-contracts.test.ts +141 -0
- package/src/op/activity-contracts.ts +96 -0
- package/src/op/audit-op.ts +54 -0
- package/src/op/builders.ts +82 -0
- package/src/op/slo-rule-tests.test.ts +84 -0
- package/src/op/slo-rule-tests.ts +173 -0
- package/src/plugin.ts +16 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { withOpRunContext } from "@intentius/chant/op";
|
|
6
|
+
import { alertmanagerSilence, alertmanagerUnsilence, apiMatchers, recordPath } from "./silence";
|
|
7
|
+
|
|
8
|
+
/** An Alertmanager API stand-in: creates silences, expires them once, then answers "already expired". */
|
|
9
|
+
function fakeAlertmanager() {
|
|
10
|
+
const created: Array<{ id: string; body: Record<string, unknown> }> = [];
|
|
11
|
+
const expired = new Set<string>();
|
|
12
|
+
const calls: string[] = [];
|
|
13
|
+
let n = 0;
|
|
14
|
+
const f = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
15
|
+
const url = String(input);
|
|
16
|
+
calls.push(`${init?.method ?? "GET"} ${url}`);
|
|
17
|
+
if (init?.method === "POST" && url.endsWith("/api/v2/silences")) {
|
|
18
|
+
const id = `s-${++n}`;
|
|
19
|
+
created.push({ id, body: JSON.parse(String(init.body)) });
|
|
20
|
+
return Response.json({ silenceID: id });
|
|
21
|
+
}
|
|
22
|
+
const m = /\/api\/v2\/silence\/(.+)$/.exec(url);
|
|
23
|
+
if (init?.method === "DELETE" && m) {
|
|
24
|
+
const id = decodeURIComponent(m[1]);
|
|
25
|
+
if (!created.some((c) => c.id === id)) return new Response("silence not found", { status: 404 });
|
|
26
|
+
if (expired.has(id)) return new Response(`silence ${id} already expired`, { status: 500 });
|
|
27
|
+
expired.add(id);
|
|
28
|
+
return new Response(null, { status: 200 });
|
|
29
|
+
}
|
|
30
|
+
return new Response("no", { status: 400 });
|
|
31
|
+
}) as typeof fetch;
|
|
32
|
+
return { f, created, expired, calls };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const NOW = () => new Date("2026-10-06T12:00:00.000Z");
|
|
36
|
+
|
|
37
|
+
describe("Alertmanager matchers", () => {
|
|
38
|
+
test("matcher strings and label sets as API matchers", () => {
|
|
39
|
+
expect(apiMatchers(['alertname="SloBurn"', 'severity=~"page|ticket"', 'env!="dev"'])).toEqual([
|
|
40
|
+
{ name: "alertname", value: "SloBurn", isRegex: false, isEqual: true },
|
|
41
|
+
{ name: "severity", value: "page|ticket", isRegex: true, isEqual: true },
|
|
42
|
+
{ name: "env", value: "dev", isRegex: false, isEqual: false },
|
|
43
|
+
]);
|
|
44
|
+
expect(apiMatchers({ slo: "checkout" })).toEqual([{ name: "slo", value: "checkout", isRegex: false, isEqual: true }]);
|
|
45
|
+
expect(() => apiMatchers(["not a matcher"])).toThrow(/matcher/);
|
|
46
|
+
expect(() => apiMatchers([])).toThrow(/at least one/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a record name cannot leave the record directory", () => {
|
|
50
|
+
expect(() => recordPath("../x")).toThrow(/record/);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("alertmanagerSilence and alertmanagerUnsilence", () => {
|
|
55
|
+
test("silence, record the id, then expire it from the record", async () => {
|
|
56
|
+
const am = fakeAlertmanager();
|
|
57
|
+
const recordDir = mkdtempSync(join(tmpdir(), "chant-silence-"));
|
|
58
|
+
const r = await withOpRunContext({ op: "deploy", runId: "run-1", passedGates: [] }, () =>
|
|
59
|
+
alertmanagerSilence({ url: "http://am:9093/", matchers: { slo: "checkout" }, duration: "30m", recordDir, record: "deploy", _fetch: am.f, _now: NOW }),
|
|
60
|
+
);
|
|
61
|
+
expect(r).toMatchObject({ silenceId: "s-1", url: "http://am:9093", startsAt: "2026-10-06T12:00:00.000Z", endsAt: "2026-10-06T12:30:00.000Z" });
|
|
62
|
+
expect(am.created[0].body).toMatchObject({ createdBy: "chant op deploy", comment: "chant run deploy (run-1)", matchers: [{ name: "slo", value: "checkout" }] });
|
|
63
|
+
const recorded = JSON.parse(readFileSync(r.record, "utf8"));
|
|
64
|
+
expect(recorded.silences).toEqual([{ silenceId: "s-1", url: "http://am:9093", endsAt: "2026-10-06T12:30:00.000Z", run: "run-1" }]);
|
|
65
|
+
|
|
66
|
+
const u = await alertmanagerUnsilence({ recordDir, record: "deploy", _fetch: am.f });
|
|
67
|
+
expect(u.expired).toEqual(["s-1"]);
|
|
68
|
+
expect(am.calls).toContain("DELETE http://am:9093/api/v2/silence/s-1");
|
|
69
|
+
expect(JSON.parse(readFileSync(r.record, "utf8")).silences).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("expiring twice, or a silence Alertmanager does not know, is done, not an error", async () => {
|
|
73
|
+
const am = fakeAlertmanager();
|
|
74
|
+
const recordDir = mkdtempSync(join(tmpdir(), "chant-silence-"));
|
|
75
|
+
await alertmanagerSilence({ url: "http://am:9093", matchers: ['alertname="X"'], duration: "1h", recordDir, _fetch: am.f });
|
|
76
|
+
await alertmanagerUnsilence({ url: "http://am:9093", silenceId: "s-1", recordDir, _fetch: am.f });
|
|
77
|
+
await expect(alertmanagerUnsilence({ url: "http://am:9093", silenceId: "s-1", recordDir, _fetch: am.f })).resolves.toMatchObject({ expired: ["s-1"] });
|
|
78
|
+
await expect(alertmanagerUnsilence({ url: "http://am:9093", silenceId: "nope", recordDir, _fetch: am.f })).resolves.toMatchObject({ expired: ["nope"] });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("with nothing recorded, unsilence does nothing and writes no record", async () => {
|
|
82
|
+
const am = fakeAlertmanager();
|
|
83
|
+
const recordDir = mkdtempSync(join(tmpdir(), "chant-silence-"));
|
|
84
|
+
await expect(alertmanagerUnsilence({ recordDir, _fetch: am.f })).resolves.toEqual({ expired: [], failed: [] });
|
|
85
|
+
expect(am.calls).toEqual([]);
|
|
86
|
+
expect(existsSync(join(recordDir, "silence.json"))).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a silence that cannot be expired stays recorded and fails the step", async () => {
|
|
90
|
+
const am = fakeAlertmanager();
|
|
91
|
+
const recordDir = mkdtempSync(join(tmpdir(), "chant-silence-"));
|
|
92
|
+
await alertmanagerSilence({ url: "http://am:9093", matchers: { a: "1" }, duration: "1h", recordDir, _fetch: am.f });
|
|
93
|
+
const down = (async () => {
|
|
94
|
+
throw new TypeError("fetch failed");
|
|
95
|
+
}) as unknown as typeof fetch;
|
|
96
|
+
await expect(alertmanagerUnsilence({ recordDir, _fetch: down })).rejects.toThrow(/could not expire 1 silence/);
|
|
97
|
+
expect(JSON.parse(readFileSync(join(recordDir, "silence.json"), "utf8")).silences).toHaveLength(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a bad duration or an API error fails before anything is recorded", async () => {
|
|
101
|
+
const recordDir = mkdtempSync(join(tmpdir(), "chant-silence-"));
|
|
102
|
+
await expect(alertmanagerSilence({ matchers: { a: "1" }, duration: "soon", recordDir })).rejects.toThrow(/not a Prometheus duration/);
|
|
103
|
+
const refuse = (async () => new Response("bad matchers", { status: 400 })) as unknown as typeof fetch;
|
|
104
|
+
await expect(alertmanagerSilence({ matchers: { a: "1" }, duration: "5m", recordDir, _fetch: refuse })).rejects.toThrow(/HTTP 400/);
|
|
105
|
+
expect(existsSync(join(recordDir, "silence.json"))).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alertmanager silences as Op steps (#3369): silence the alerts a change is
|
|
3
|
+
* expected to set off, and expire the silence when the change is done or
|
|
4
|
+
* has failed.
|
|
5
|
+
*
|
|
6
|
+
* `alertmanagerSilence` creates a silence through `POST /api/v2/silences`
|
|
7
|
+
* and records its id in a file under the working directory
|
|
8
|
+
* (`.chant/alertmanager-silences/<record>.json`). Step-output references are
|
|
9
|
+
* not available in an Op's `onFailure` phases, so the record is how a final
|
|
10
|
+
* phase and an `onFailure` phase alike find the silence:
|
|
11
|
+
* `alertmanagerUnsilence({ record })` expires every silence the record
|
|
12
|
+
* holds through `DELETE /api/v2/silence/{id}`, and drops them from it. An
|
|
13
|
+
* already-expired or unknown silence counts as expired, so the step can
|
|
14
|
+
* run in both places.
|
|
15
|
+
*
|
|
16
|
+
* The Alertmanager is `url`, else `$ALERTMANAGER_URL`, else
|
|
17
|
+
* `http://localhost:9093`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join, resolve } from "node:path";
|
|
22
|
+
import { currentOpRun } from "@intentius/chant/op";
|
|
23
|
+
import { durationMs } from "../../duration";
|
|
24
|
+
import { parseMatchers } from "../../matchers";
|
|
25
|
+
|
|
26
|
+
export interface AlertmanagerSilenceArgs {
|
|
27
|
+
/** The Alertmanager's base URL. */
|
|
28
|
+
url?: string;
|
|
29
|
+
/**
|
|
30
|
+
* The alerts to silence: matcher strings (`alertname="X"`,
|
|
31
|
+
* `severity=~"page|ticket"`, or `{a="1", b="2"}`), or a label set matched
|
|
32
|
+
* by equality.
|
|
33
|
+
*/
|
|
34
|
+
matchers: string[] | Record<string, string>;
|
|
35
|
+
/** How long the silence lasts, e.g. `30m`. Expiring it earlier is `alertmanagerUnsilence`'s job. */
|
|
36
|
+
duration: string;
|
|
37
|
+
comment?: string;
|
|
38
|
+
/** Default `chant`, with the Op's name when run in one. */
|
|
39
|
+
createdBy?: string;
|
|
40
|
+
/** The record the id is kept in. Default `silence`. */
|
|
41
|
+
record?: string;
|
|
42
|
+
/** Where records live, relative to the working directory. Default `.chant/alertmanager-silences`. */
|
|
43
|
+
recordDir?: string;
|
|
44
|
+
/** Replaces fetch. For tests. */
|
|
45
|
+
_fetch?: typeof fetch;
|
|
46
|
+
/** Replaces the clock. For tests. */
|
|
47
|
+
_now?: () => Date;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AlertmanagerSilenceResult {
|
|
51
|
+
silenceId: string;
|
|
52
|
+
url: string;
|
|
53
|
+
startsAt: string;
|
|
54
|
+
endsAt: string;
|
|
55
|
+
/** The record file the id was written to. */
|
|
56
|
+
record: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AlertmanagerUnsilenceArgs {
|
|
60
|
+
url?: string;
|
|
61
|
+
/** Expire this silence only, in place of the record's. */
|
|
62
|
+
silenceId?: string;
|
|
63
|
+
/** The record to expire. Default `silence`. */
|
|
64
|
+
record?: string;
|
|
65
|
+
recordDir?: string;
|
|
66
|
+
_fetch?: typeof fetch;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AlertmanagerUnsilenceResult {
|
|
70
|
+
expired: string[];
|
|
71
|
+
/** Silences that could not be expired, kept in the record for the next try. */
|
|
72
|
+
failed: { silenceId: string; detail: string }[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface RecordedSilence {
|
|
76
|
+
silenceId: string;
|
|
77
|
+
url: string;
|
|
78
|
+
endsAt: string;
|
|
79
|
+
run?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function alertmanagerUrl(url?: string): string {
|
|
83
|
+
return (url ?? process.env.ALERTMANAGER_URL ?? "http://localhost:9093").replace(/\/+$/, "");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The API matchers for `matchers`. */
|
|
87
|
+
export function apiMatchers(matchers: string[] | Record<string, string>): Array<{ name: string; value: string; isRegex: boolean; isEqual: boolean }> {
|
|
88
|
+
if (!Array.isArray(matchers)) {
|
|
89
|
+
return Object.entries(matchers).map(([name, value]) => ({ name, value, isRegex: false, isEqual: true }));
|
|
90
|
+
}
|
|
91
|
+
const out: Array<{ name: string; value: string; isRegex: boolean; isEqual: boolean }> = [];
|
|
92
|
+
for (const entry of matchers) {
|
|
93
|
+
const parsed = parseMatchers(entry);
|
|
94
|
+
if (!parsed.ok) throw new Error(`matcher ${JSON.stringify(entry)}: ${parsed.error}`);
|
|
95
|
+
for (const m of parsed.matchers) out.push({ name: m.name, value: m.value, isRegex: m.op.endsWith("~"), isEqual: !m.op.startsWith("!") });
|
|
96
|
+
}
|
|
97
|
+
if (out.length === 0) throw new Error("a silence needs at least one matcher");
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function recordPath(record = "silence", recordDir = ".chant/alertmanager-silences", cwd = process.cwd()): string {
|
|
102
|
+
if (!/^[A-Za-z0-9._-]+$/.test(record)) throw new Error(`record ${JSON.stringify(record)}: use letters, digits, dot, dash and underscore`);
|
|
103
|
+
return join(resolve(cwd, recordDir), `${record}.json`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readRecord(path: string): RecordedSilence[] {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as { silences?: RecordedSilence[] };
|
|
109
|
+
return Array.isArray(parsed.silences) ? parsed.silences : [];
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeRecord(path: string, silences: RecordedSilence[]): void {
|
|
116
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
117
|
+
writeFileSync(path, `${JSON.stringify({ silences }, null, 2)}\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Create a silence and record its id. */
|
|
121
|
+
export async function alertmanagerSilence(args: AlertmanagerSilenceArgs): Promise<AlertmanagerSilenceResult> {
|
|
122
|
+
const f = args._fetch ?? fetch;
|
|
123
|
+
const url = alertmanagerUrl(args.url);
|
|
124
|
+
const ms = durationMs(args.duration);
|
|
125
|
+
if (!ms || ms <= 0) throw new Error(`duration ${JSON.stringify(args.duration)} is not a Prometheus duration such as 30m`);
|
|
126
|
+
const run = currentOpRun();
|
|
127
|
+
const now = (args._now ?? (() => new Date()))();
|
|
128
|
+
const startsAt = now.toISOString();
|
|
129
|
+
const endsAt = new Date(now.getTime() + ms).toISOString();
|
|
130
|
+
const body = {
|
|
131
|
+
matchers: apiMatchers(args.matchers),
|
|
132
|
+
startsAt,
|
|
133
|
+
endsAt,
|
|
134
|
+
createdBy: args.createdBy ?? (run ? `chant op ${run.op}` : "chant"),
|
|
135
|
+
comment: args.comment ?? (run ? `chant run ${run.op} (${run.runId})` : "chant"),
|
|
136
|
+
};
|
|
137
|
+
const res = await f(`${url}/api/v2/silences`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
138
|
+
const text = await res.text();
|
|
139
|
+
if (!res.ok) throw new Error(`POST ${url}/api/v2/silences: HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
140
|
+
const silenceId = (JSON.parse(text) as { silenceID?: string }).silenceID;
|
|
141
|
+
if (!silenceId) throw new Error(`POST ${url}/api/v2/silences answered without a silenceID: ${text.slice(0, 300)}`);
|
|
142
|
+
const path = recordPath(args.record, args.recordDir);
|
|
143
|
+
writeRecord(path, [...readRecord(path), { silenceId, url, endsAt, ...(run ? { run: run.runId } : {}) }]);
|
|
144
|
+
return { silenceId, url, startsAt, endsAt, record: path };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Expire the recorded silences (or `silenceId`), and drop the expired ones from the record. */
|
|
148
|
+
export async function alertmanagerUnsilence(args: AlertmanagerUnsilenceArgs = {}): Promise<AlertmanagerUnsilenceResult> {
|
|
149
|
+
const f = args._fetch ?? fetch;
|
|
150
|
+
const path = recordPath(args.record, args.recordDir);
|
|
151
|
+
const recorded = readRecord(path);
|
|
152
|
+
const targets: RecordedSilence[] = args.silenceId
|
|
153
|
+
? [recorded.find((s) => s.silenceId === args.silenceId) ?? { silenceId: args.silenceId, url: alertmanagerUrl(args.url), endsAt: "" }]
|
|
154
|
+
: recorded;
|
|
155
|
+
const expired: string[] = [];
|
|
156
|
+
const failed: AlertmanagerUnsilenceResult["failed"] = [];
|
|
157
|
+
for (const s of targets) {
|
|
158
|
+
const base = args.url ? alertmanagerUrl(args.url) : s.url;
|
|
159
|
+
try {
|
|
160
|
+
const res = await f(`${base}/api/v2/silence/${encodeURIComponent(s.silenceId)}`, { method: "DELETE" });
|
|
161
|
+
const text = await res.text().catch(() => "");
|
|
162
|
+
// Alertmanager answers 404 for an unknown silence and 500 "already expired" for an expired one: both are done.
|
|
163
|
+
if (res.ok || res.status === 404 || /expired/i.test(text)) expired.push(s.silenceId);
|
|
164
|
+
else failed.push({ silenceId: s.silenceId, detail: `HTTP ${res.status}: ${text.slice(0, 200)}` });
|
|
165
|
+
} catch (err) {
|
|
166
|
+
failed.push({ silenceId: s.silenceId, detail: (err as Error).message });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const done = new Set(expired);
|
|
170
|
+
if (recorded.length > 0) writeRecord(path, recorded.filter((s) => !done.has(s.silenceId)));
|
|
171
|
+
if (failed.length > 0) {
|
|
172
|
+
throw new Error(`could not expire ${failed.length} silence(s): ${failed.map((x) => `${x.silenceId} (${x.detail})`).join("; ")}`);
|
|
173
|
+
}
|
|
174
|
+
return { expired, failed };
|
|
175
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ConvergeOp,
|
|
4
|
+
Op,
|
|
5
|
+
eq,
|
|
6
|
+
isActivityContract,
|
|
7
|
+
loadActivities,
|
|
8
|
+
loadActivityContracts,
|
|
9
|
+
phase,
|
|
10
|
+
report,
|
|
11
|
+
validateActivitySteps,
|
|
12
|
+
validateStepOutputRefs,
|
|
13
|
+
when,
|
|
14
|
+
type ActivityContract,
|
|
15
|
+
type ResourceSymptom,
|
|
16
|
+
} from "@intentius/chant/op";
|
|
17
|
+
import { load } from "js-yaml";
|
|
18
|
+
import * as contracts from "./activity-contracts";
|
|
19
|
+
import * as activities from "./activities";
|
|
20
|
+
import {
|
|
21
|
+
alertmanagerSilence,
|
|
22
|
+
alertmanagerUnsilence,
|
|
23
|
+
amtoolCheckConfig,
|
|
24
|
+
amtoolRoutesTest,
|
|
25
|
+
promtoolCheckRules,
|
|
26
|
+
promtoolTestRules,
|
|
27
|
+
ruleAudit,
|
|
28
|
+
rulesLoadedObserve,
|
|
29
|
+
} from "./builders";
|
|
30
|
+
import { RuleAuditOp } from "./audit-op";
|
|
31
|
+
import { Slo } from "../composites/slo";
|
|
32
|
+
|
|
33
|
+
const CONTRACTS: Map<string, ActivityContract> = new Map(
|
|
34
|
+
Object.values(contracts).filter(isActivityContract).map((c) => [c.name, c]),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const op = (steps: unknown[]) => ({ name: "check", phases: [{ name: "Check", steps: steps as never[] }] });
|
|
38
|
+
|
|
39
|
+
describe("prometheus activity contracts (#3369)", () => {
|
|
40
|
+
test("every exported activity has a contract with a return schema, and nothing else is exported", () => {
|
|
41
|
+
for (const [key, value] of Object.entries(contracts)) {
|
|
42
|
+
expect(isActivityContract(value), `${key} is not an ActivityContract`).toBe(true);
|
|
43
|
+
expect((value as ActivityContract).returns).toBeDefined();
|
|
44
|
+
}
|
|
45
|
+
const fns = Object.entries(activities).filter(([, v]) => typeof v === "function").map(([k]) => k).sort();
|
|
46
|
+
expect([...CONTRACTS.keys()].sort()).toEqual(fns);
|
|
47
|
+
expect(fns).toEqual([
|
|
48
|
+
"alertmanagerSilence",
|
|
49
|
+
"alertmanagerUnsilence",
|
|
50
|
+
"amtoolCheckConfig",
|
|
51
|
+
"amtoolRoutesTest",
|
|
52
|
+
"promtoolCheckRules",
|
|
53
|
+
"promtoolTestRules",
|
|
54
|
+
"ruleAudit",
|
|
55
|
+
"rulesLoadedObserve",
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the builders' steps validate, and a misspelled key is an error", () => {
|
|
60
|
+
const steps = [
|
|
61
|
+
promtoolCheckRules({ rules: "dist/prometheus/rules.yml" }),
|
|
62
|
+
promtoolTestRules({ rules: "dist/prometheus/rules.yml", tests: ["tests/slo.test.yml"] }),
|
|
63
|
+
amtoolCheckConfig({ config: "dist/prometheus/alertmanager.yml" }),
|
|
64
|
+
amtoolRoutesTest({ config: "dist/prometheus/alertmanager.yml", labels: { severity: "page" }, expect: "oncall" }),
|
|
65
|
+
alertmanagerSilence({ matchers: ['alertname="SloBurn"'], duration: "30m", record: "deploy" }),
|
|
66
|
+
alertmanagerUnsilence({ record: "deploy" }),
|
|
67
|
+
ruleAudit({ mode: "issue", selectorBudget: 10 }),
|
|
68
|
+
];
|
|
69
|
+
expect(validateActivitySteps(op(steps), CONTRACTS)).toEqual([]);
|
|
70
|
+
const issues = validateActivitySteps(op([{ kind: "activity", fn: "alertmanagerSilence", args: { matchers: {}, duraton: "5m" } }]), CONTRACTS);
|
|
71
|
+
expect(issues.some((i) => i.message.includes("duraton"))).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("promtoolTestRules({ slos }) generates the tests at build time", () => {
|
|
75
|
+
const slo = Slo({ name: "checkout", objective: 0.99, window: "30d", sli: { good: 'sum(rate(req_total{code="200"}[{{window}}]))', total: "sum(rate(req_total[{{window}}]))" } });
|
|
76
|
+
const step = promtoolTestRules({ rules: "dist/prometheus/rules.yml", slos: [{ slo, good: 'req_total{code="200"}', bad: 'req_total{code="500"}' }] });
|
|
77
|
+
const docs = step.args?.testYaml as string[];
|
|
78
|
+
expect(docs.length).toBe(2);
|
|
79
|
+
expect((load(docs[0]) as { rule_files: string[] }).rule_files).toEqual(["rules.yml"]);
|
|
80
|
+
expect(step.args).not.toHaveProperty("slos");
|
|
81
|
+
expect(validateActivitySteps(op([step]), CONTRACTS)).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("ConvergeOp({ observe: rulesLoadedObserve }) passes OPS012 and OPS013", () => {
|
|
85
|
+
const { op: converge } = ConvergeOp({
|
|
86
|
+
name: "rules-loaded",
|
|
87
|
+
env: "local",
|
|
88
|
+
schedule: "*/5 * * * *",
|
|
89
|
+
observe: rulesLoadedObserve({ url: "http://localhost:9090", rules: "dist/prometheus/rules.yml" }),
|
|
90
|
+
rules: [when<ResourceSymptom>(eq("status", "drifted"), report("rule group not loaded or failing"), { id: "group-drift", why: "say so" })],
|
|
91
|
+
});
|
|
92
|
+
expect(validateActivitySteps(converge.props as never, CONTRACTS)).toEqual([]);
|
|
93
|
+
expect(validateStepOutputRefs(converge.props as never, CONTRACTS)).toEqual([]);
|
|
94
|
+
expect(validateStepOutputRefs(converge.props as never, new Map()).length).toBeGreaterThan(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a silence around a change: the final phase reads the id; onFailure expires through the record", () => {
|
|
98
|
+
const silence = alertmanagerSilence({ id: "silence", matchers: { slo: "checkout" }, duration: "30m", record: "deploy" });
|
|
99
|
+
const change = Op({
|
|
100
|
+
name: "deploy",
|
|
101
|
+
overview: "deploy with alerts silenced",
|
|
102
|
+
phases: [phase("Silence", [silence]), phase("Unsilence", [alertmanagerUnsilence({ silenceId: silence.out.silenceId, record: "deploy" })])],
|
|
103
|
+
onFailure: [phase("Unsilence", [alertmanagerUnsilence({ record: "deploy" })])],
|
|
104
|
+
});
|
|
105
|
+
expect(validateActivitySteps(change.props as never, CONTRACTS)).toEqual([]);
|
|
106
|
+
expect(validateStepOutputRefs(change.props as never, CONTRACTS)).toEqual([]);
|
|
107
|
+
expect(silence.profile).toBe("atMostOnce");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("RuleAuditOp is one ruleAudit step, its findings the run's outcome", () => {
|
|
111
|
+
const { op: audit } = RuleAuditOp({ name: "rule-audit", url: "http://prom:9090", schedule: "0 * * * *", onFinding: "issue", pendingFor: "2h" });
|
|
112
|
+
const props = audit.props as unknown as { schedule?: unknown; phases: Array<{ steps: Array<Record<string, unknown>> }> };
|
|
113
|
+
expect(props.schedule).toEqual({ cron: "0 * * * *", overlap: "skip" });
|
|
114
|
+
expect(props.phases[0].steps[0]).toMatchObject({
|
|
115
|
+
fn: "ruleAudit",
|
|
116
|
+
args: { mode: "issue", url: "http://prom:9090", pendingFor: "2h" },
|
|
117
|
+
outcomeAttribute: { name: "Findings", from: "findings" },
|
|
118
|
+
});
|
|
119
|
+
expect(validateActivitySteps(props as never, CONTRACTS)).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("loadActivities and loadActivityContracts find them by the prometheus lexicon's name", async () => {
|
|
123
|
+
const acts = await loadActivities(["prometheus"]);
|
|
124
|
+
for (const fn of ["promtoolCheckRules", "rulesLoadedObserve", "alertmanagerSilence", "ruleAudit"]) expect(acts.has(fn), fn).toBe(true);
|
|
125
|
+
const loaded = await loadActivityContracts(["prometheus"]);
|
|
126
|
+
expect(loaded.get("rulesLoadedObserve")?.returns).toBeDefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── Compile-time only ────────────────────────────────────────────────
|
|
131
|
+
function _typeChecksOnly(): void {
|
|
132
|
+
// @ts-expect-error: duration is required.
|
|
133
|
+
alertmanagerSilence({ matchers: { a: "1" } });
|
|
134
|
+
// @ts-expect-error: labels are strings.
|
|
135
|
+
amtoolRoutesTest({ config: "a.yml", labels: { replicas: 3 }, expect: "x" });
|
|
136
|
+
// @ts-expect-error: mode is report or issue.
|
|
137
|
+
ruleAudit({ mode: "pull-request" });
|
|
138
|
+
// @ts-expect-error: the test seam is not an authoring option.
|
|
139
|
+
rulesLoadedObserve({ groups: ["g"], _fetch: fetch });
|
|
140
|
+
}
|
|
141
|
+
void _typeChecksOnly;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity contracts for this lexicon's `op/activities` (#3369).
|
|
3
|
+
*
|
|
4
|
+
* Resolved by convention at
|
|
5
|
+
* `@intentius/chant-lexicon-prometheus/op/activity-contracts`:
|
|
6
|
+
* `loadActivityContracts` merges them into the map OPS012 checks each step's
|
|
7
|
+
* args against and OPS013 checks each step-output reference against. Every
|
|
8
|
+
* activity here has one; `rulesLoadedObserve`'s return schema is what lets a
|
|
9
|
+
* `ConvergeOp({ observe: rulesLoadedObserve(...) })` pass OPS013, and
|
|
10
|
+
* `alertmanagerSilence`'s is what a later step reads `.out.silenceId` from.
|
|
11
|
+
*
|
|
12
|
+
* Each `args` schema mirrors the `*Args` interface in `./activities/`,
|
|
13
|
+
* without the `_`-prefixed test seams, and is a `z.strictObject`, so a
|
|
14
|
+
* misspelled key fails the build.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { activityContract } from "@intentius/chant/op";
|
|
19
|
+
|
|
20
|
+
const oneOrMany = z.union([z.string(), z.array(z.string())]);
|
|
21
|
+
const toolResult = z.object({ files: z.array(z.string()), ok: z.boolean(), output: z.string() });
|
|
22
|
+
const resourceStatus = z.enum(["in-sync", "drifted", "unknown"]);
|
|
23
|
+
|
|
24
|
+
export const promtoolCheckRulesContract = activityContract(
|
|
25
|
+
"promtoolCheckRules",
|
|
26
|
+
z.strictObject({ rules: oneOrMany, bin: z.string().optional() }),
|
|
27
|
+
toolResult,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export const promtoolTestRulesContract = activityContract(
|
|
31
|
+
"promtoolTestRules",
|
|
32
|
+
z.strictObject({ rules: z.string(), tests: oneOrMany.optional(), testYaml: oneOrMany.optional(), bin: z.string().optional() }),
|
|
33
|
+
toolResult.extend({ tests: z.number() }),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export const amtoolCheckConfigContract = activityContract(
|
|
37
|
+
"amtoolCheckConfig",
|
|
38
|
+
z.strictObject({ config: z.string(), bin: z.string().optional() }),
|
|
39
|
+
toolResult,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export const amtoolRoutesTestContract = activityContract(
|
|
43
|
+
"amtoolRoutesTest",
|
|
44
|
+
z.strictObject({ config: z.string(), labels: z.record(z.string(), z.string()), expect: oneOrMany, bin: z.string().optional() }),
|
|
45
|
+
z.object({ config: z.string(), labels: z.record(z.string(), z.string()), receivers: z.array(z.string()), ok: z.boolean() }),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const record = { record: z.string().optional(), recordDir: z.string().optional() };
|
|
49
|
+
|
|
50
|
+
export const alertmanagerSilenceContract = activityContract(
|
|
51
|
+
"alertmanagerSilence",
|
|
52
|
+
z.strictObject({
|
|
53
|
+
url: z.string().optional(),
|
|
54
|
+
matchers: z.union([z.array(z.string()), z.record(z.string(), z.string())]),
|
|
55
|
+
duration: z.string(),
|
|
56
|
+
comment: z.string().optional(),
|
|
57
|
+
createdBy: z.string().optional(),
|
|
58
|
+
...record,
|
|
59
|
+
}),
|
|
60
|
+
z.object({ silenceId: z.string(), url: z.string(), startsAt: z.string(), endsAt: z.string(), record: z.string() }),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export const alertmanagerUnsilenceContract = activityContract(
|
|
64
|
+
"alertmanagerUnsilence",
|
|
65
|
+
z.strictObject({ url: z.string().optional(), silenceId: z.string().optional(), ...record }),
|
|
66
|
+
z.object({ expired: z.array(z.string()), failed: z.array(z.object({ silenceId: z.string(), detail: z.string() })) }),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
export const rulesLoadedObserveContract = activityContract(
|
|
70
|
+
"rulesLoadedObserve",
|
|
71
|
+
z.strictObject({ url: z.string().optional(), groups: z.array(z.string()).optional(), rules: oneOrMany.optional(), timeoutMs: z.number().optional() }),
|
|
72
|
+
z.object({ resources: z.array(z.object({ name: z.string(), status: resourceStatus, detail: z.string() })) }),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
export const ruleAuditContract = activityContract(
|
|
76
|
+
"ruleAudit",
|
|
77
|
+
z.strictObject({
|
|
78
|
+
url: z.string().optional(),
|
|
79
|
+
pendingFor: z.string().optional(),
|
|
80
|
+
firingFor: z.string().optional(),
|
|
81
|
+
selectorBudget: z.number().optional(),
|
|
82
|
+
lookback: z.string().optional(),
|
|
83
|
+
mode: z.enum(["report", "issue"]).optional(),
|
|
84
|
+
issueTitle: z.string().optional(),
|
|
85
|
+
}),
|
|
86
|
+
z.object({
|
|
87
|
+
mode: z.enum(["report", "issue"]),
|
|
88
|
+
findings: z.array(
|
|
89
|
+
z.object({ kind: z.enum(["rule-error", "pending-too-long", "firing-too-long", "selector-no-series"]), subject: z.string(), detail: z.string() }),
|
|
90
|
+
),
|
|
91
|
+
queried: z.number(),
|
|
92
|
+
unchecked: z.number(),
|
|
93
|
+
summary: z.string(),
|
|
94
|
+
issueUrl: z.string().optional(),
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RuleAuditOp`: what a live Prometheus says about its rules, as an Op with
|
|
3
|
+
* an optional schedule (#3369). One `ruleAudit` step reports groups with
|
|
4
|
+
* health errors, alerts pending or firing past a threshold, and selectors
|
|
5
|
+
* no live target emits (one query per selector, within a budget).
|
|
6
|
+
* `onFinding` is `report` (the default) or `issue`, which keeps one open
|
|
7
|
+
* issue current.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* export const { op } = RuleAuditOp({ name: "rule-audit", url: "http://prometheus:9090", schedule: "0 * * * *" });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Op, phase, type OpResource } from "@intentius/chant/op";
|
|
16
|
+
import { ruleAudit } from "./builders";
|
|
17
|
+
import type { RuleAuditMode } from "./activities/rule-audit";
|
|
18
|
+
|
|
19
|
+
export interface RuleAuditOpConfig {
|
|
20
|
+
/** Op name (kebab-case). */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Prometheus's base URL. Default `$PROMETHEUS_URL`, else `http://localhost:9090`. */
|
|
23
|
+
url?: string;
|
|
24
|
+
/** Cron expression; omit for one-shot `chant run`. */
|
|
25
|
+
schedule?: string;
|
|
26
|
+
/** @default "report" */
|
|
27
|
+
onFinding?: RuleAuditMode;
|
|
28
|
+
/** @default "1h" */
|
|
29
|
+
pendingFor?: string;
|
|
30
|
+
/** @default "24h" */
|
|
31
|
+
firingFor?: string;
|
|
32
|
+
/** @default 50 */
|
|
33
|
+
selectorBudget?: number;
|
|
34
|
+
/** @default "1h" */
|
|
35
|
+
lookback?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RuleAuditOpResources {
|
|
39
|
+
op: InstanceType<typeof OpResource>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function RuleAuditOp(config: RuleAuditOpConfig): RuleAuditOpResources {
|
|
43
|
+
const { name, schedule, onFinding, ...rest } = config;
|
|
44
|
+
const defined = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)) as Omit<RuleAuditOpConfig, "name" | "schedule" | "onFinding">;
|
|
45
|
+
const step = ruleAudit({ mode: onFinding ?? "report", ...defined });
|
|
46
|
+
const op = Op({
|
|
47
|
+
name,
|
|
48
|
+
overview: "Audit a live Prometheus: rule health, alerts pending or firing too long, and selectors nothing emits",
|
|
49
|
+
labels: { Audit: "true", Surface: "prometheus-rules" },
|
|
50
|
+
...(schedule ? { schedule: { cron: schedule, overlap: "skip" as const } } : {}),
|
|
51
|
+
phases: [phase("Audit", [{ ...step, outcomeAttribute: { name: "Findings", from: "findings" } }])],
|
|
52
|
+
});
|
|
53
|
+
return { op };
|
|
54
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed step builders for this lexicon's Op activities (#3369), following
|
|
3
|
+
* the helm and fly builders: each one's options ARE the activity's own
|
|
4
|
+
* `*Args` interface (minus its `_`-prefixed test seams), every field also
|
|
5
|
+
* takes a `StepOutputRef`, and `profile` and `id` go to the step.
|
|
6
|
+
*
|
|
7
|
+
* `promtoolCheckRules`, `promtoolTestRules` and `amtoolCheckConfig` share
|
|
8
|
+
* their names with the plain functions in `../tools.ts`, which the package
|
|
9
|
+
* root already exports, so those three builders are reached here, at
|
|
10
|
+
* `@intentius/chant-lexicon-prometheus/op/builders`. The root exports the
|
|
11
|
+
* rest.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { activity, takeProfileAndId, type ActivityStep, type NamedActivityStep, type WithStepRefs } from "@intentius/chant/op";
|
|
15
|
+
import type {
|
|
16
|
+
AmtoolCheckConfigArgs,
|
|
17
|
+
AmtoolRoutesTestArgs,
|
|
18
|
+
PromtoolCheckRulesArgs,
|
|
19
|
+
PromtoolTestRulesArgs,
|
|
20
|
+
} from "./activities/promtool";
|
|
21
|
+
import type { AlertmanagerSilenceArgs, AlertmanagerUnsilenceArgs } from "./activities/silence";
|
|
22
|
+
import type { RulesLoadedObserveArgs } from "./activities/rules-loaded";
|
|
23
|
+
import type { RuleAuditArgs } from "./activities/rule-audit";
|
|
24
|
+
import { sloRuleTests, type SloTestInput } from "./slo-rule-tests";
|
|
25
|
+
|
|
26
|
+
type StepOpts = { profile?: ActivityStep["profile"]; id?: string };
|
|
27
|
+
type Authored<T> = Omit<T, `_${string}`>;
|
|
28
|
+
|
|
29
|
+
function step<Args>(fn: string, defaultProfile: NonNullable<ActivityStep["profile"]>) {
|
|
30
|
+
return (args: WithStepRefs<Authored<Args>> & StepOpts): NamedActivityStep => {
|
|
31
|
+
const { args: rest, profile, id } = takeProfileAndId(args as Record<string, unknown>);
|
|
32
|
+
return activity(fn, rest, { profile: profile ?? defaultProfile, ...(id ? { id } : {}) });
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `promtool check rules` over built rule files. Defaults to the `fastIdempotent` profile. */
|
|
37
|
+
export const promtoolCheckRules = step<PromtoolCheckRulesArgs>("promtoolCheckRules", "fastIdempotent");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `promtool test rules` over a built rule file. `slos` generates the tests
|
|
41
|
+
* from `Slo` declarations at build time (`sloRuleTests`): each burn-rate
|
|
42
|
+
* pair fires at its rate and not below it. Defaults to the `fastIdempotent`
|
|
43
|
+
* profile.
|
|
44
|
+
*/
|
|
45
|
+
export const promtoolTestRules = (
|
|
46
|
+
args: WithStepRefs<Authored<PromtoolTestRulesArgs>> & StepOpts & { slos?: SloTestInput[] },
|
|
47
|
+
): NamedActivityStep => {
|
|
48
|
+
const { slos, ...rest } = args;
|
|
49
|
+
if (!slos || slos.length === 0) return step<PromtoolTestRulesArgs>("promtoolTestRules", "fastIdempotent")(rest);
|
|
50
|
+
const generated = sloRuleTests(slos);
|
|
51
|
+
const given = rest.testYaml === undefined ? [] : Array.isArray(rest.testYaml) ? rest.testYaml : [rest.testYaml];
|
|
52
|
+
return step<PromtoolTestRulesArgs>("promtoolTestRules", "fastIdempotent")({ ...rest, testYaml: [...(given as string[]), ...generated] });
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** `amtool check-config` over a built Alertmanager config. Defaults to the `fastIdempotent` profile. */
|
|
56
|
+
export const amtoolCheckConfig = step<AmtoolCheckConfigArgs>("amtoolCheckConfig", "fastIdempotent");
|
|
57
|
+
|
|
58
|
+
/** `amtool config routes test`: `labels` route to `expect`. Defaults to the `fastIdempotent` profile. */
|
|
59
|
+
export const amtoolRoutesTest = step<AmtoolRoutesTestArgs>("amtoolRoutesTest", "fastIdempotent");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Silence alerts through Alertmanager's API for `duration`, recording the
|
|
63
|
+
* silence id under `record` so `alertmanagerUnsilence` can expire it from
|
|
64
|
+
* a final phase or an `onFailure` phase. Defaults to the `atMostOnce`
|
|
65
|
+
* profile: a retry after a lost answer would create a second silence.
|
|
66
|
+
*/
|
|
67
|
+
export const alertmanagerSilence = step<AlertmanagerSilenceArgs>("alertmanagerSilence", "atMostOnce");
|
|
68
|
+
|
|
69
|
+
/** Expire the silences `record` holds (or `silenceId`). Defaults to the `fastIdempotent` profile. */
|
|
70
|
+
export const alertmanagerUnsilence = (args: WithStepRefs<Authored<AlertmanagerUnsilenceArgs>> & StepOpts = {}): NamedActivityStep =>
|
|
71
|
+
step<AlertmanagerUnsilenceArgs>("alertmanagerUnsilence", "fastIdempotent")(args);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Observe declared rule groups for a `ConvergeOp({ observe })`: one resource
|
|
75
|
+
* per group, drifted when Prometheus has not loaded it or a rule in it has
|
|
76
|
+
* `health: "err"`. Defaults to the `fastIdempotent` profile.
|
|
77
|
+
*/
|
|
78
|
+
export const rulesLoadedObserve = step<RulesLoadedObserveArgs>("rulesLoadedObserve", "fastIdempotent");
|
|
79
|
+
|
|
80
|
+
/** Audit a live Prometheus's rules, alerts and selectors (the step of `RuleAuditOp`). Defaults to the `fastIdempotent` profile. */
|
|
81
|
+
export const ruleAudit = (args: WithStepRefs<Authored<RuleAuditArgs>> & StepOpts = {}): NamedActivityStep =>
|
|
82
|
+
step<RuleAuditArgs>("ruleAudit", "fastIdempotent")(args);
|