@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
package/src/api/bind.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding the ruler and the Alertmanager for one environment: the config
|
|
3
|
+
* lookup, the targets, the transports. Shared by observe, live export and
|
|
4
|
+
* the ruler apply target (#3372), so all three reach the same endpoints.
|
|
5
|
+
*
|
|
6
|
+
* The two bind independently. A project that declares only rule groups has
|
|
7
|
+
* no Alertmanager to bind, and that must not make its groups unobservable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ChantConfig } from "@intentius/chant/config";
|
|
11
|
+
import type { UnobservedReason } from "@intentius/chant/observation";
|
|
12
|
+
import { PromApiError, PromClient, promHttp, type PromHttp } from "./client";
|
|
13
|
+
import { AlertmanagerApi } from "./alertmanager";
|
|
14
|
+
import { RulerApi } from "./ruler";
|
|
15
|
+
import { isUnresolvedTarget, resolveAlertmanagerTarget, resolveRulerTarget, type UnresolvedTarget } from "../config";
|
|
16
|
+
|
|
17
|
+
/** What callers may inject: tests pass `http` or point the config at a fake server; the CLI passes nothing. */
|
|
18
|
+
export interface BindOptions {
|
|
19
|
+
environment?: string;
|
|
20
|
+
/** The project's config. Loaded from `cwd` (default: the working directory) when omitted. */
|
|
21
|
+
config?: Pick<ChantConfig, "prometheus">;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
env?: Record<string, string | undefined>;
|
|
24
|
+
/** Transport overrides, per endpoint. The targets are still resolved, so a test exercises the binding too. */
|
|
25
|
+
http?: { ruler?: PromHttp; alertmanager?: PromHttp };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A target that could not be resolved. */
|
|
29
|
+
export class PromBindingError extends Error {
|
|
30
|
+
constructor(readonly unresolved: UnresolvedTarget) {
|
|
31
|
+
super(unresolved.detail);
|
|
32
|
+
this.name = "PromBindingError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Both endpoints, each bound or the reason it is not. */
|
|
37
|
+
export interface BoundEndpoints {
|
|
38
|
+
ruler: RulerApi | UnresolvedTarget;
|
|
39
|
+
alertmanager: AlertmanagerApi | UnresolvedTarget;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loadConfig(cwd: string): Promise<Pick<ChantConfig, "prometheus"> | undefined> {
|
|
43
|
+
try {
|
|
44
|
+
const { loadChantConfig } = await import("@intentius/chant/config");
|
|
45
|
+
return (await loadChantConfig(cwd)).config;
|
|
46
|
+
} catch {
|
|
47
|
+
// No project config (an ad-hoc import): the environment variables still bind.
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Resolve both endpoints for an environment. Never throws for a missing binding; each side says why it is unbound. */
|
|
53
|
+
export async function bindEndpoints(options: BindOptions = {}): Promise<BoundEndpoints> {
|
|
54
|
+
const config = options.config ?? (await loadConfig(options.cwd ?? process.cwd()));
|
|
55
|
+
const input = { environment: options.environment, config, env: options.env };
|
|
56
|
+
const r = resolveRulerTarget(input);
|
|
57
|
+
const a = resolveAlertmanagerTarget(input);
|
|
58
|
+
return {
|
|
59
|
+
ruler: isUnresolvedTarget(r) ? r : new RulerApi(new PromClient(options.http?.ruler ?? promHttp(r), r), r),
|
|
60
|
+
alertmanager: isUnresolvedTarget(a) ? a : new AlertmanagerApi(new PromClient(options.http?.alertmanager ?? promHttp(a), a), a),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The ruler alone, for a caller (the apply target) that needs it bound. Throws {@link PromBindingError} when there is none. */
|
|
65
|
+
export async function bindRuler(options: BindOptions = {}): Promise<RulerApi> {
|
|
66
|
+
const { ruler } = await bindEndpoints(options);
|
|
67
|
+
if (isUnresolvedTarget(ruler)) throw new PromBindingError(ruler);
|
|
68
|
+
return ruler;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The Alertmanager alone. Throws {@link PromBindingError} when there is none. */
|
|
72
|
+
export async function bindAlertmanager(options: BindOptions = {}): Promise<AlertmanagerApi> {
|
|
73
|
+
const { alertmanager } = await bindEndpoints(options);
|
|
74
|
+
if (isUnresolvedTarget(alertmanager)) throw new PromBindingError(alertmanager);
|
|
75
|
+
return alertmanager;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What a failed bind or read means, in the observation vocabulary. */
|
|
79
|
+
export function classifyPromFailure(err: unknown): { reason: UnobservedReason; detail: string } {
|
|
80
|
+
if (err instanceof PromBindingError) return { reason: err.unresolved.reason, detail: err.unresolved.detail };
|
|
81
|
+
if (err instanceof PromApiError) {
|
|
82
|
+
return err.verdict === "refused"
|
|
83
|
+
? { reason: "no-credentials", detail: `${err.message} (the credentials were refused)` }
|
|
84
|
+
: { reason: "read-failed", detail: err.message };
|
|
85
|
+
}
|
|
86
|
+
return { reason: "read-failed", detail: err instanceof Error ? err.message.split("\n")[0] : String(err) };
|
|
87
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP transport for the ruler and Alertmanager APIs (#3371): one client
|
|
3
|
+
* for observe and live export here, and for the ruler apply target (#3372),
|
|
4
|
+
* so every path reaches the same endpoint with the same tenant and
|
|
5
|
+
* credentials.
|
|
6
|
+
*
|
|
7
|
+
* `PromHttp` is the seam. The default is `fetch` against a resolved target;
|
|
8
|
+
* a test hands in a function, or points the target at a local fake server
|
|
9
|
+
* (./fake-servers.ts).
|
|
10
|
+
*
|
|
11
|
+
* Bodies are text. The ruler's config API speaks YAML both ways, the
|
|
12
|
+
* Prometheus-compatible APIs answer JSON, and Mimir's Alertmanager config
|
|
13
|
+
* API answers YAML, so the caller decides how to read a body.
|
|
14
|
+
*
|
|
15
|
+
* Status mapping is in one place ({@link statusVerdict}): a 404 is the only
|
|
16
|
+
* answer that means "not there", 401 and 403 mean the credentials were
|
|
17
|
+
* refused, anything else that is not a 2xx is a failed read.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** How the client authenticates. */
|
|
21
|
+
export type PromAuth = { readonly token: string } | { readonly user: string; readonly password: string };
|
|
22
|
+
|
|
23
|
+
/** A resolved endpoint: where, as which tenant, as whom, and where that came from. */
|
|
24
|
+
export interface PromTarget {
|
|
25
|
+
/** Base URL, without a trailing slash. */
|
|
26
|
+
readonly url: string;
|
|
27
|
+
/** `X-Scope-OrgID`, for a multi-tenant Mimir, Cortex or Loki. */
|
|
28
|
+
readonly tenant?: string;
|
|
29
|
+
readonly auth?: PromAuth;
|
|
30
|
+
/** Where the binding came from: `prometheus.profiles.prod.ruler`, `env PROMETHEUS_RULER_URL`. */
|
|
31
|
+
readonly source: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PromResponse {
|
|
35
|
+
readonly status: number;
|
|
36
|
+
readonly text: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One request. `path` starts with `/` and is relative to the target's URL. */
|
|
40
|
+
export type PromHttp = (method: string, path: string, body?: { text: string; contentType: string }) => Promise<PromResponse>;
|
|
41
|
+
|
|
42
|
+
function authHeader(auth: PromAuth | undefined): Record<string, string> {
|
|
43
|
+
if (!auth) return {};
|
|
44
|
+
if ("token" in auth) return { authorization: `Bearer ${auth.token}` };
|
|
45
|
+
return { authorization: `Basic ${Buffer.from(`${auth.user}:${auth.password}`).toString("base64")}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The default transport: `fetch` against the target, with the tenant header and credentials on every request. */
|
|
49
|
+
export function promHttp(target: PromTarget, fetchImpl: typeof fetch = fetch): PromHttp {
|
|
50
|
+
const base = target.url.replace(/\/+$/, "");
|
|
51
|
+
const headers: Record<string, string> = {
|
|
52
|
+
accept: "application/json, application/yaml;q=0.9, */*;q=0.1",
|
|
53
|
+
...authHeader(target.auth),
|
|
54
|
+
...(target.tenant ? { "x-scope-orgid": target.tenant } : {}),
|
|
55
|
+
};
|
|
56
|
+
return async (method, path, body) => {
|
|
57
|
+
const res = await fetchImpl(`${base}${path}`, {
|
|
58
|
+
method,
|
|
59
|
+
headers: body === undefined ? headers : { ...headers, "content-type": body.contentType },
|
|
60
|
+
...(body === undefined ? {} : { body: body.text }),
|
|
61
|
+
});
|
|
62
|
+
return { status: res.status, text: await res.text() };
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What a status means for a read. */
|
|
67
|
+
export type StatusVerdict = "ok" | "not-found" | "refused" | "failed";
|
|
68
|
+
|
|
69
|
+
export function statusVerdict(status: number): StatusVerdict {
|
|
70
|
+
if (status >= 200 && status < 300) return "ok";
|
|
71
|
+
if (status === 404) return "not-found";
|
|
72
|
+
if (status === 401 || status === 403) return "refused";
|
|
73
|
+
return "failed";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A non-2xx answer, with enough to classify it and to say where it came from. */
|
|
77
|
+
export class PromApiError extends Error {
|
|
78
|
+
readonly verdict: StatusVerdict;
|
|
79
|
+
constructor(
|
|
80
|
+
readonly status: number,
|
|
81
|
+
readonly method: string,
|
|
82
|
+
readonly path: string,
|
|
83
|
+
body: string,
|
|
84
|
+
) {
|
|
85
|
+
const detail = body.trim().split("\n")[0].slice(0, 200);
|
|
86
|
+
super(`${method} ${path} returned ${status}${detail ? `: ${detail}` : ""}`);
|
|
87
|
+
this.name = "PromApiError";
|
|
88
|
+
this.verdict = statusVerdict(status);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A bound client: the transport, the target it reaches, and a per-client memo so N reads share one lookup. */
|
|
93
|
+
export class PromClient {
|
|
94
|
+
private readonly memo = new Map<string, Promise<unknown>>();
|
|
95
|
+
|
|
96
|
+
constructor(
|
|
97
|
+
readonly http: PromHttp,
|
|
98
|
+
readonly target: PromTarget,
|
|
99
|
+
) {}
|
|
100
|
+
|
|
101
|
+
/** GET, returning the body text; undefined on 404; throws {@link PromApiError} on any other non-2xx. */
|
|
102
|
+
async getText(path: string): Promise<string | undefined> {
|
|
103
|
+
const res = await this.http("GET", path);
|
|
104
|
+
const verdict = statusVerdict(res.status);
|
|
105
|
+
if (verdict === "ok") return res.text;
|
|
106
|
+
if (verdict === "not-found") return undefined;
|
|
107
|
+
throw new PromApiError(res.status, "GET", path, res.text);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** GET a JSON body; undefined on 404. A 2xx body that is not JSON is a failed read. */
|
|
111
|
+
async getJson<T = unknown>(path: string): Promise<T | undefined> {
|
|
112
|
+
const text = await this.getText(path);
|
|
113
|
+
if (text === undefined) return undefined;
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(text) as T;
|
|
116
|
+
} catch {
|
|
117
|
+
throw new PromApiError(502, "GET", path, `the answer is not JSON: ${text.slice(0, 80)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A write. Resolves on a 2xx; throws {@link PromApiError} otherwise, a 404 included. */
|
|
122
|
+
async send(method: "POST" | "PUT" | "DELETE", path: string, body?: { text: string; contentType: string }): Promise<PromResponse> {
|
|
123
|
+
const res = await this.http(method, path, body);
|
|
124
|
+
if (statusVerdict(res.status) !== "ok") throw new PromApiError(res.status, method, path, res.text);
|
|
125
|
+
return res;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Run `fn` once per key for this client's lifetime; concurrent callers share the promise. A failure is not cached. */
|
|
129
|
+
once<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
130
|
+
let p = this.memo.get(key) as Promise<T> | undefined;
|
|
131
|
+
if (!p) {
|
|
132
|
+
p = fn();
|
|
133
|
+
this.memo.set(key, p);
|
|
134
|
+
p.catch(() => this.memo.delete(key));
|
|
135
|
+
}
|
|
136
|
+
return p;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** `/api/v1/rules` groups mapped to the rule-file shape and to a health summary (#3371). */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
import { evaluatedToRuleGroup, groupHealth } from "./evaluated";
|
|
5
|
+
import type { EvaluatedGroup } from "./ruler";
|
|
6
|
+
|
|
7
|
+
// The shape Prometheus 3.x answers `/api/v1/rules` with, one group.
|
|
8
|
+
const api: EvaluatedGroup = {
|
|
9
|
+
name: "api",
|
|
10
|
+
file: "/etc/prometheus/rules/api.yml",
|
|
11
|
+
interval: 30,
|
|
12
|
+
limit: 0,
|
|
13
|
+
evaluationTime: 0.0012,
|
|
14
|
+
lastEvaluation: "2026-10-06T08:00:00Z",
|
|
15
|
+
rules: [
|
|
16
|
+
{ type: "recording", name: "job:http_requests:rate5m", query: "sum by (job) (rate(http_requests_total[5m]))", labels: {}, health: "ok" },
|
|
17
|
+
{
|
|
18
|
+
type: "alerting",
|
|
19
|
+
name: "ApiHighErrorRate",
|
|
20
|
+
query: 'sum(rate(http_requests_total{code=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 0.05',
|
|
21
|
+
duration: 600,
|
|
22
|
+
keepFiringFor: 0,
|
|
23
|
+
labels: { severity: "page" },
|
|
24
|
+
annotations: { summary: "API 5xx ratio above 5%" },
|
|
25
|
+
health: "ok",
|
|
26
|
+
state: "firing",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("evaluatedToRuleGroup", () => {
|
|
32
|
+
test("maps names, queries and second durations back to rule fields", () => {
|
|
33
|
+
expect(evaluatedToRuleGroup(api)).toEqual({
|
|
34
|
+
name: "api",
|
|
35
|
+
interval: "30s",
|
|
36
|
+
rules: [
|
|
37
|
+
{ record: "job:http_requests:rate5m", expr: "sum by (job) (rate(http_requests_total[5m]))" },
|
|
38
|
+
{
|
|
39
|
+
alert: "ApiHighErrorRate",
|
|
40
|
+
expr: 'sum(rate(http_requests_total{code=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 0.05',
|
|
41
|
+
for: "10m",
|
|
42
|
+
labels: { severity: "page" },
|
|
43
|
+
annotations: { summary: "API 5xx ratio above 5%" },
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a mixed duration and a keep_firing_for are written the way Prometheus writes them", () => {
|
|
50
|
+
const g = evaluatedToRuleGroup({ ...api, interval: 90, rules: [{ type: "alerting", name: "A", query: "up == 0", duration: 3600, keepFiringFor: 300 }] });
|
|
51
|
+
expect(g.interval).toBe("1m30s");
|
|
52
|
+
expect(g.rules[0]).toEqual({ alert: "A", expr: "up == 0", for: "1h", keep_firing_for: "5m" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a positive limit is kept", () => {
|
|
56
|
+
expect(evaluatedToRuleGroup({ ...api, limit: 10 }).limit).toBe(10);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("groupHealth", () => {
|
|
61
|
+
test("every rule ok is ok, with firing and pending counted", () => {
|
|
62
|
+
expect(groupHealth(api)).toMatchObject({ health: "ok", failing: 0, firing: 1, pending: 0 });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("one failing rule makes the group err and names the first error", () => {
|
|
66
|
+
const g = { ...api, rules: [{ ...api.rules[0], health: "err", lastError: "vector contains metrics with the same labelset" }, api.rules[1]] };
|
|
67
|
+
expect(groupHealth(g)).toMatchObject({
|
|
68
|
+
health: "err",
|
|
69
|
+
failing: 1,
|
|
70
|
+
lastError: "job:http_requests:rate5m: vector contains metrics with the same labelset",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a rule not yet evaluated, or an empty group, is unknown", () => {
|
|
75
|
+
expect(groupHealth({ ...api, rules: [{ ...api.rules[0], health: "unknown" }] }).health).toBe("unknown");
|
|
76
|
+
expect(groupHealth({ ...api, rules: [] }).health).toBe("unknown");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `/api/v1/rules` says about a group, mapped two ways (#3371): to the
|
|
3
|
+
* group's health for `describeResources`, and, for a plain Prometheus,
|
|
4
|
+
* which has no rule group API, back to the rule-file shape for import.
|
|
5
|
+
*
|
|
6
|
+
* The second mapping is lossy, and says so. `/api/v1/rules` reports rules
|
|
7
|
+
* as Prometheus evaluates them, not as the file wrote them: `query` is the
|
|
8
|
+
* expression as PromQL prints it, durations are seconds, and a group's
|
|
9
|
+
* `interval` is the one it runs on, the global `evaluation_interval` when
|
|
10
|
+
* the group set none. A group's `labels` and `query_offset` are not
|
|
11
|
+
* reported at all.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { formatDuration } from "../duration";
|
|
15
|
+
import type { RuleConfig, RuleGroupConfig } from "../model";
|
|
16
|
+
import type { EvaluatedGroup } from "./ruler";
|
|
17
|
+
|
|
18
|
+
/** A duration in seconds as Prometheus writes one; undefined for zero or a value that is not a number. */
|
|
19
|
+
function seconds(v: unknown): string | undefined {
|
|
20
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return undefined;
|
|
21
|
+
return formatDuration(Math.round(v * 1000));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function labelsOf(v: unknown): Record<string, string> | undefined {
|
|
25
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) return undefined;
|
|
26
|
+
const entries = Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, String(x)] as const);
|
|
27
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What the import of evaluated groups leaves out, said once per export. */
|
|
31
|
+
export const EVALUATED_IMPORT_WARNING =
|
|
32
|
+
"rule groups were read from /api/v1/rules, which reports them as Prometheus evaluates them: expressions are as PromQL prints them, " +
|
|
33
|
+
"a group's interval is the one it runs on (the global evaluation_interval when the group sets none), and group labels and query_offset are not reported";
|
|
34
|
+
|
|
35
|
+
/** A group from `/api/v1/rules` in the rule-file shape. */
|
|
36
|
+
export function evaluatedToRuleGroup(group: EvaluatedGroup): RuleGroupConfig {
|
|
37
|
+
const rules: RuleConfig[] = group.rules.map((r) => {
|
|
38
|
+
if (r.type === "recording") {
|
|
39
|
+
const labels = labelsOf(r.labels);
|
|
40
|
+
return { record: r.name, expr: r.query, ...(labels ? { labels } : {}) };
|
|
41
|
+
}
|
|
42
|
+
const labels = labelsOf(r.labels);
|
|
43
|
+
const annotations = labelsOf(r.annotations);
|
|
44
|
+
const forDuration = seconds(r.duration);
|
|
45
|
+
const keep = seconds(r.keepFiringFor);
|
|
46
|
+
return {
|
|
47
|
+
alert: r.name,
|
|
48
|
+
expr: r.query,
|
|
49
|
+
...(forDuration ? { for: forDuration } : {}),
|
|
50
|
+
...(keep ? { keep_firing_for: keep } : {}),
|
|
51
|
+
...(labels ? { labels } : {}),
|
|
52
|
+
...(annotations ? { annotations } : {}),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
const interval = seconds(group.interval);
|
|
56
|
+
return {
|
|
57
|
+
name: group.name,
|
|
58
|
+
...(interval ? { interval } : {}),
|
|
59
|
+
...(typeof group.limit === "number" && group.limit > 0 ? { limit: group.limit } : {}),
|
|
60
|
+
rules,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A group's health, from its rules: `err` when any rule failed its last evaluation, `ok` when every rule passed, else `unknown`. */
|
|
65
|
+
export interface GroupHealth {
|
|
66
|
+
health: "ok" | "err" | "unknown";
|
|
67
|
+
/** The first rule error, as `<rule>: <error>`. */
|
|
68
|
+
lastError?: string;
|
|
69
|
+
/** How many rules failed their last evaluation. */
|
|
70
|
+
failing: number;
|
|
71
|
+
/** Alerting rules by state. */
|
|
72
|
+
firing: number;
|
|
73
|
+
pending: number;
|
|
74
|
+
lastEvaluation?: string;
|
|
75
|
+
/** Seconds the last evaluation took. */
|
|
76
|
+
evaluationTime?: number;
|
|
77
|
+
/** Seconds. */
|
|
78
|
+
interval?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function groupHealth(group: EvaluatedGroup): GroupHealth {
|
|
82
|
+
let failing = 0;
|
|
83
|
+
let unknown = 0;
|
|
84
|
+
let firing = 0;
|
|
85
|
+
let pending = 0;
|
|
86
|
+
let lastError: string | undefined;
|
|
87
|
+
for (const r of group.rules) {
|
|
88
|
+
if (r.health === "err") {
|
|
89
|
+
failing++;
|
|
90
|
+
lastError ??= `${r.name}: ${r.lastError ?? "evaluation failed"}`;
|
|
91
|
+
} else if (r.health !== "ok") unknown++;
|
|
92
|
+
if (r.state === "firing") firing++;
|
|
93
|
+
else if (r.state === "pending") pending++;
|
|
94
|
+
}
|
|
95
|
+
const health = failing > 0 ? "err" : unknown > 0 || group.rules.length === 0 ? "unknown" : "ok";
|
|
96
|
+
return {
|
|
97
|
+
health,
|
|
98
|
+
...(lastError ? { lastError } : {}),
|
|
99
|
+
failing,
|
|
100
|
+
firing,
|
|
101
|
+
pending,
|
|
102
|
+
...(group.lastEvaluation ? { lastEvaluation: group.lastEvaluation } : {}),
|
|
103
|
+
...(typeof group.evaluationTime === "number" ? { evaluationTime: group.evaluationTime } : {}),
|
|
104
|
+
...(typeof group.interval === "number" ? { interval: group.interval } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ruler and an Alertmanager in memory, for the observe and export tests
|
|
3
|
+
* (#3371), and for the ruler apply target's (#3372).
|
|
4
|
+
*
|
|
5
|
+
* Each fake is a request handler over a state the test sets up. It answers
|
|
6
|
+
* the routes ./ruler.ts and ./alertmanager.ts use, in the shapes the
|
|
7
|
+
* projects document: the ruler config API's YAML (a mapping of namespace to
|
|
8
|
+
* groups; one group alone at `/{namespace}/{group}`; 404 for a namespace or
|
|
9
|
+
* group that is not there), `/api/v1/rules` JSON, Alertmanager's
|
|
10
|
+
* `/api/v2/status` and Mimir's `/api/v1/alerts`. With `tenant` set it
|
|
11
|
+
* answers 401 to a request without that `X-Scope-OrgID`, as Mimir does
|
|
12
|
+
* ("no org id").
|
|
13
|
+
*
|
|
14
|
+
* {@link serveFake} puts a handler behind a real HTTP server on localhost,
|
|
15
|
+
* so a test drives the default `fetch` transport, headers included.
|
|
16
|
+
* {@link fakeHttp} hands the same handler in as a {@link PromHttp}.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createServer, type IncomingMessage } from "node:http";
|
|
20
|
+
import type { AddressInfo } from "node:net";
|
|
21
|
+
import { dump } from "js-yaml";
|
|
22
|
+
import { loadPrometheusYaml } from "../import/parser";
|
|
23
|
+
import type { RulerKind } from "../config";
|
|
24
|
+
import type { PromHttp } from "./client";
|
|
25
|
+
import type { EvaluatedGroup, RawRuleGroup } from "./ruler";
|
|
26
|
+
|
|
27
|
+
export interface FakeRequest {
|
|
28
|
+
method: string;
|
|
29
|
+
path: string;
|
|
30
|
+
headers: Record<string, string | undefined>;
|
|
31
|
+
body?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FakeResponse {
|
|
35
|
+
status: number;
|
|
36
|
+
text: string;
|
|
37
|
+
contentType?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type FakeHandler = (req: FakeRequest) => FakeResponse;
|
|
41
|
+
|
|
42
|
+
export interface FakeRulerState {
|
|
43
|
+
kind: RulerKind;
|
|
44
|
+
/** Groups by namespace (on a plain Prometheus, by rule file). */
|
|
45
|
+
namespaces: Record<string, RawRuleGroup[]>;
|
|
46
|
+
/** What `/api/v1/rules` reports. Defaults to every group, every rule healthy and inactive. */
|
|
47
|
+
evaluated?: EvaluatedGroup[];
|
|
48
|
+
/** Required `X-Scope-OrgID`. */
|
|
49
|
+
tenant?: string;
|
|
50
|
+
/** Answer every request with this status. */
|
|
51
|
+
status?: number;
|
|
52
|
+
/** Answer `/api/v1/rules` with this status (a ruler whose evaluation API is down). */
|
|
53
|
+
evaluatedStatus?: number;
|
|
54
|
+
prometheusPrefix?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FakeAlertmanagerState {
|
|
58
|
+
kind: "alertmanager" | "mimir" | "cortex";
|
|
59
|
+
/** `/api/v2/status` `config.original`. */
|
|
60
|
+
original?: string;
|
|
61
|
+
/** Mimir and Cortex: the uploaded config, absent for a tenant with none. */
|
|
62
|
+
uploaded?: { alertmanager_config: string; template_files?: Record<string, string> };
|
|
63
|
+
cluster?: string;
|
|
64
|
+
version?: string;
|
|
65
|
+
tenant?: string;
|
|
66
|
+
status?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const yaml = (v: unknown) => dump(v, { lineWidth: -1, noRefs: true });
|
|
70
|
+
const ok = (text: string, contentType = "application/yaml"): FakeResponse => ({ status: 200, text, contentType });
|
|
71
|
+
const json = (v: unknown): FakeResponse => ({ status: 200, text: JSON.stringify(v), contentType: "application/json" });
|
|
72
|
+
const notFound = (what: string): FakeResponse => ({ status: 404, text: what, contentType: "text/plain" });
|
|
73
|
+
|
|
74
|
+
function tenantRefused(tenant: string | undefined, req: FakeRequest): FakeResponse | undefined {
|
|
75
|
+
if (tenant === undefined) return undefined;
|
|
76
|
+
return req.headers["x-scope-orgid"] === tenant ? undefined : { status: 401, text: "no org id", contentType: "text/plain" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What `/api/v1/rules` reports for a group nobody set an evaluation for: every rule healthy. */
|
|
80
|
+
export function evaluatedOf(file: string, group: RawRuleGroup): EvaluatedGroup {
|
|
81
|
+
const rules = Array.isArray(group.rules) ? (group.rules as Array<Record<string, unknown>>) : [];
|
|
82
|
+
return {
|
|
83
|
+
name: group.name,
|
|
84
|
+
file,
|
|
85
|
+
interval: 60,
|
|
86
|
+
rules: rules.map((r) =>
|
|
87
|
+
typeof r.alert === "string"
|
|
88
|
+
? { type: "alerting", name: r.alert, query: String(r.expr), duration: 0, labels: (r.labels as Record<string, string>) ?? {}, annotations: (r.annotations as Record<string, string>) ?? {}, health: "ok", state: "inactive" }
|
|
89
|
+
: { type: "recording", name: String(r.record), query: String(r.expr), labels: (r.labels as Record<string, string>) ?? {}, health: "ok" },
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A ruler over `state`. Writes change `state`, so a test can read back what an apply did. */
|
|
95
|
+
export function fakeRuler(state: FakeRulerState, calls: string[] = []): FakeHandler {
|
|
96
|
+
const prefix = state.prometheusPrefix ?? (state.kind === "prometheus" ? "" : "/prometheus");
|
|
97
|
+
const base = state.kind === "mimir" ? `${prefix}/config/v1/rules` : state.kind === "cortex" ? "/api/v1/rules" : state.kind === "loki" ? "/loki/api/v1/rules" : undefined;
|
|
98
|
+
return (req) => {
|
|
99
|
+
calls.push(`${req.method} ${req.path}`);
|
|
100
|
+
if (state.status !== undefined) return { status: state.status, text: "refused" };
|
|
101
|
+
const refused = tenantRefused(state.tenant, req);
|
|
102
|
+
if (refused) return refused;
|
|
103
|
+
const route = req.path.split("?")[0];
|
|
104
|
+
|
|
105
|
+
if (route === `${prefix}/api/v1/rules` && req.method === "GET") {
|
|
106
|
+
if (state.evaluatedStatus !== undefined) return { status: state.evaluatedStatus, text: "evaluation unavailable" };
|
|
107
|
+
const groups = state.evaluated ?? Object.entries(state.namespaces).flatMap(([ns, gs]) => gs.map((g) => evaluatedOf(ns, g)));
|
|
108
|
+
return json({ status: "success", data: { groups } });
|
|
109
|
+
}
|
|
110
|
+
if (base === undefined || (route !== base && !route.startsWith(`${base}/`))) return notFound("404 page not found");
|
|
111
|
+
|
|
112
|
+
const parts = route.slice(base.length).split("/").filter(Boolean).map(decodeURIComponent);
|
|
113
|
+
if (req.method === "GET") {
|
|
114
|
+
if (parts.length === 0) {
|
|
115
|
+
const nonEmpty = Object.fromEntries(Object.entries(state.namespaces).filter(([, gs]) => gs.length > 0));
|
|
116
|
+
return Object.keys(nonEmpty).length === 0 ? notFound("no rule groups found") : ok(yaml(nonEmpty));
|
|
117
|
+
}
|
|
118
|
+
const groups = state.namespaces[parts[0]];
|
|
119
|
+
if (!groups || groups.length === 0) return notFound("no rule groups found");
|
|
120
|
+
if (parts.length === 1) return ok(yaml({ [parts[0]]: groups }));
|
|
121
|
+
const g = groups.find((x) => x.name === parts[1]);
|
|
122
|
+
return g ? ok(yaml(g)) : notFound("group does not exist");
|
|
123
|
+
}
|
|
124
|
+
if (req.method === "POST" && parts.length === 1) {
|
|
125
|
+
const doc = loadPrometheusYaml(req.body ?? "") as RawRuleGroup;
|
|
126
|
+
const groups = (state.namespaces[parts[0]] ??= []);
|
|
127
|
+
const i = groups.findIndex((x) => x.name === doc.name);
|
|
128
|
+
if (i >= 0) groups[i] = doc;
|
|
129
|
+
else groups.push(doc);
|
|
130
|
+
return { status: 202, text: "" };
|
|
131
|
+
}
|
|
132
|
+
if (req.method === "DELETE" && parts.length === 2) {
|
|
133
|
+
const groups = state.namespaces[parts[0]];
|
|
134
|
+
const i = groups ? groups.findIndex((x) => x.name === parts[1]) : -1;
|
|
135
|
+
if (i < 0) return notFound("group does not exist");
|
|
136
|
+
groups.splice(i, 1);
|
|
137
|
+
return { status: 202, text: "" };
|
|
138
|
+
}
|
|
139
|
+
return { status: 405, text: "method not allowed" };
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** An Alertmanager over `state`: a plain one's `/api/v2/status`, or Mimir's and Cortex's `/api/v1/alerts` and per-tenant status. */
|
|
144
|
+
export function fakeAlertmanager(state: FakeAlertmanagerState, calls: string[] = []): FakeHandler {
|
|
145
|
+
const statusPath = state.kind === "alertmanager" ? "/api/v2/status" : "/alertmanager/api/v2/status";
|
|
146
|
+
return (req) => {
|
|
147
|
+
calls.push(`${req.method} ${req.path}`);
|
|
148
|
+
if (state.status !== undefined) return { status: state.status, text: "refused" };
|
|
149
|
+
const refused = tenantRefused(state.tenant, req);
|
|
150
|
+
if (refused) return refused;
|
|
151
|
+
if (req.path === statusPath && req.method === "GET") {
|
|
152
|
+
const original = state.kind === "alertmanager" ? state.original : state.uploaded?.alertmanager_config;
|
|
153
|
+
if (original === undefined) return notFound("the Alertmanager is not configured");
|
|
154
|
+
return json({
|
|
155
|
+
cluster: { name: "01FAKE", peers: [{ address: "10.0.0.1:9094", name: "01FAKE" }], status: state.cluster ?? "ready" },
|
|
156
|
+
config: { original },
|
|
157
|
+
uptime: "2026-10-06T08:00:00.000Z",
|
|
158
|
+
versionInfo: { branch: "HEAD", buildDate: "20260101-00:00:00", buildUser: "root@fake", goVersion: "go1.25", revision: "fake", version: state.version ?? "0.34.1" },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (req.path === "/api/v1/alerts" && state.kind !== "alertmanager") {
|
|
162
|
+
if (req.method === "GET") {
|
|
163
|
+
if (!state.uploaded) return notFound("alertmanager storage object not found");
|
|
164
|
+
return ok(yaml({ template_files: state.uploaded.template_files ?? {}, alertmanager_config: state.uploaded.alertmanager_config }));
|
|
165
|
+
}
|
|
166
|
+
if (req.method === "POST") {
|
|
167
|
+
const doc = loadPrometheusYaml(req.body ?? "") as { alertmanager_config: string; template_files?: Record<string, string> };
|
|
168
|
+
state.uploaded = { alertmanager_config: doc.alertmanager_config, template_files: doc.template_files ?? {} };
|
|
169
|
+
return { status: 201, text: "" };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return notFound("404 page not found");
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A handler as a {@link PromHttp}, with no headers (a tenant-checking fake then refuses; use {@link serveFake} for those). */
|
|
177
|
+
export function fakeHttp(handler: FakeHandler, headers: Record<string, string> = {}): PromHttp {
|
|
178
|
+
return async (method, path, body) => {
|
|
179
|
+
const res = handler({ method, path, headers, body: body?.text });
|
|
180
|
+
return { status: res.status, text: res.text };
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
let data = "";
|
|
187
|
+
req.setEncoding("utf8");
|
|
188
|
+
req.on("data", (c: string) => (data += c));
|
|
189
|
+
req.on("end", () => resolve(data));
|
|
190
|
+
req.on("error", reject);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A handler behind a real HTTP server on 127.0.0.1, on a free port. `close()` stops it. */
|
|
195
|
+
export async function serveFake(handler: FakeHandler): Promise<{ url: string; close: () => Promise<void> }> {
|
|
196
|
+
const server = createServer(async (req, res) => {
|
|
197
|
+
const body = await readBody(req);
|
|
198
|
+
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(",") : v]));
|
|
199
|
+
const out = handler({ method: req.method ?? "GET", path: req.url ?? "/", headers, ...(body ? { body } : {}) });
|
|
200
|
+
res.writeHead(out.status, { "content-type": out.contentType ?? "text/plain" });
|
|
201
|
+
res.end(out.text);
|
|
202
|
+
});
|
|
203
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
204
|
+
const { port } = server.address() as AddressInfo;
|
|
205
|
+
return {
|
|
206
|
+
url: `http://127.0.0.1:${port}`,
|
|
207
|
+
close: () => new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
|
|
208
|
+
};
|
|
209
|
+
}
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ruler and Alertmanager API clients (#3371), as
|
|
3
|
+
* `@intentius/chant-lexicon-prometheus/api`.
|
|
4
|
+
*
|
|
5
|
+
* One transport for observe, live export and the ruler apply target
|
|
6
|
+
* (#3372): bind with {@link bindEndpoints} (or {@link bindRuler} /
|
|
7
|
+
* {@link bindAlertmanager}), then read and write through {@link RulerApi}
|
|
8
|
+
* and {@link AlertmanagerApi}. The ownership boundary is
|
|
9
|
+
* {@link declaredNamespaces}: nothing outside it is read for import, and an
|
|
10
|
+
* apply must not write or delete outside it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { PromApiError, PromClient, promHttp, statusVerdict, type PromAuth, type PromHttp, type PromResponse, type PromTarget, type StatusVerdict } from "./client";
|
|
14
|
+
export { RulerApi, type EvaluatedGroup, type EvaluatedRule, type RawRuleGroup } from "./ruler";
|
|
15
|
+
export { AlertmanagerApi, type AlertmanagerHealth, type LiveAlertmanagerConfig } from "./alertmanager";
|
|
16
|
+
export { evaluatedToRuleGroup, groupHealth, type GroupHealth } from "./evaluated";
|
|
17
|
+
export { bindAlertmanager, bindEndpoints, bindRuler, classifyPromFailure, PromBindingError, type BindOptions, type BoundEndpoints } from "./bind";
|
|
18
|
+
export {
|
|
19
|
+
ALERTMANAGER_KINDS,
|
|
20
|
+
RULER_KINDS,
|
|
21
|
+
declaredNamespaces,
|
|
22
|
+
namespaceOfGroup,
|
|
23
|
+
resolveAlertmanagerTarget,
|
|
24
|
+
resolveRulerTarget,
|
|
25
|
+
type AlertmanagerKind,
|
|
26
|
+
type AlertmanagerTarget,
|
|
27
|
+
type RulerKind,
|
|
28
|
+
type RulerTarget,
|
|
29
|
+
} from "../config";
|