@intentius/chant-lexicon-prometheus 0.124.31 → 0.124.33

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 (114) hide show
  1. package/dist/alertmanager.js +61 -0
  2. package/dist/api/alertmanager.js +98 -0
  3. package/dist/api/apply.js +306 -0
  4. package/dist/api/bind.js +66 -0
  5. package/dist/api/client.js +110 -0
  6. package/dist/api/evaluated.js +86 -0
  7. package/dist/api/fake-servers.js +165 -0
  8. package/dist/api/index.js +17 -0
  9. package/dist/api/ruler.js +122 -0
  10. package/dist/build.js +240 -0
  11. package/dist/catalog.js +66 -0
  12. package/dist/codegen/docs-cli.js +3 -0
  13. package/dist/codegen/docs.js +105 -0
  14. package/dist/codegen/generate-cli.js +7 -0
  15. package/dist/codegen/generate.js +42 -0
  16. package/dist/codegen/package.js +28 -0
  17. package/dist/collector-metrics.js +225 -0
  18. package/dist/composites/alert-routing.js +210 -0
  19. package/dist/composites/catalog.js +344 -0
  20. package/dist/composites/genai.js +454 -0
  21. package/dist/composites/index.js +8 -0
  22. package/dist/composites/red-alerts.js +135 -0
  23. package/dist/composites/slo.js +353 -0
  24. package/dist/composites/watchdog.js +110 -0
  25. package/dist/config-model.js +52 -0
  26. package/dist/config.js +225 -0
  27. package/dist/describe-resources.js +299 -0
  28. package/dist/detect.js +12 -0
  29. package/dist/duration.js +54 -0
  30. package/dist/export-resources.js +163 -0
  31. package/dist/import/alertmanager-live.js +309 -0
  32. package/dist/import/embedded.js +100 -0
  33. package/dist/import/generator.js +683 -0
  34. package/dist/import/parser.js +428 -0
  35. package/dist/import/slo.js +207 -0
  36. package/dist/index.js +33 -0
  37. package/dist/init-templates.js +119 -0
  38. package/dist/integrations.js +33 -0
  39. package/dist/integrity.json +2 -2
  40. package/dist/lint/audit-catalog.js +60 -0
  41. package/dist/lint/audit-lineage.js +42 -0
  42. package/dist/lint/post-synth/index.js +68 -0
  43. package/dist/lint/post-synth/prom-helpers.js +157 -0
  44. package/dist/lint/post-synth/prom101.js +13 -0
  45. package/dist/lint/post-synth/prom102.js +13 -0
  46. package/dist/lint/post-synth/prom103.js +13 -0
  47. package/dist/lint/post-synth/prom104.js +13 -0
  48. package/dist/lint/post-synth/prom105.js +13 -0
  49. package/dist/lint/post-synth/prom106.js +13 -0
  50. package/dist/lint/post-synth/prom107.js +13 -0
  51. package/dist/lint/post-synth/prom201.js +13 -0
  52. package/dist/lint/post-synth/prom202.js +13 -0
  53. package/dist/lint/post-synth/prom203.js +13 -0
  54. package/dist/lint/post-synth/prom204.js +13 -0
  55. package/dist/lint/post-synth/prom205.js +13 -0
  56. package/dist/lint/post-synth/prom206.js +13 -0
  57. package/dist/lint/post-synth/prom207.js +13 -0
  58. package/dist/lint/post-synth/prom208.js +13 -0
  59. package/dist/lint/post-synth/prom209.js +13 -0
  60. package/dist/lint/post-synth/prom210.js +13 -0
  61. package/dist/lint/post-synth/prom211.js +17 -0
  62. package/dist/lint/post-synth/prom212.js +14 -0
  63. package/dist/lint/post-synth/prom213.js +16 -0
  64. package/dist/lint/post-synth/prom214.js +14 -0
  65. package/dist/lint/post-synth/prom215.js +14 -0
  66. package/dist/lint/post-synth/prom216.js +15 -0
  67. package/dist/lint/post-synth/prom217.js +14 -0
  68. package/dist/lint/post-synth/prom218.js +15 -0
  69. package/dist/lint/post-synth/prom219.js +14 -0
  70. package/dist/lint/post-synth/prom220.js +14 -0
  71. package/dist/lint/post-synth/prom221.js +15 -0
  72. package/dist/lint/post-synth/prom222.js +14 -0
  73. package/dist/lint/post-synth/prom223.js +15 -0
  74. package/dist/lint/post-synth/prom224.js +14 -0
  75. package/dist/lint/post-synth/prom225.js +15 -0
  76. package/dist/lint/post-synth/prom301.js +19 -0
  77. package/dist/lint/rules/index.js +8 -0
  78. package/dist/lint/rules/literal-credential.js +127 -0
  79. package/dist/lint/rules/prom-ast.js +65 -0
  80. package/dist/lint/rules/promql-literal.js +65 -0
  81. package/dist/lint/rules/slo-literal.js +79 -0
  82. package/dist/lsp/completions.js +11 -0
  83. package/dist/lsp/hover.js +21 -0
  84. package/dist/manifest.json +1 -1
  85. package/dist/matchers.js +131 -0
  86. package/dist/model.js +108 -0
  87. package/dist/op/activities/index.js +20 -0
  88. package/dist/op/activities/promtool.js +87 -0
  89. package/dist/op/activities/rule-audit.js +128 -0
  90. package/dist/op/activities/rules-loaded.js +73 -0
  91. package/dist/op/activities/silence.js +123 -0
  92. package/dist/op/activity-contracts.js +51 -0
  93. package/dist/op/audit-op.js +28 -0
  94. package/dist/op/builders.js +57 -0
  95. package/dist/op/slo-rule-tests.js +148 -0
  96. package/dist/package-cli.js +14 -0
  97. package/dist/pin.js +13 -0
  98. package/dist/plugin.js +143 -0
  99. package/dist/prometheus-config.js +38 -0
  100. package/dist/promql-analysis.js +343 -0
  101. package/dist/promql.js +44 -0
  102. package/dist/rule-eval.js +466 -0
  103. package/dist/rules.js +97 -0
  104. package/dist/serializer.js +59 -0
  105. package/dist/skill-defs.js +41 -0
  106. package/dist/tools.js +85 -0
  107. package/dist/validate-cli.js +7 -0
  108. package/dist/validate-config.js +556 -0
  109. package/dist/validate-integrations.js +388 -0
  110. package/dist/validate-relabel.js +52 -0
  111. package/dist/validate-security.js +135 -0
  112. package/dist/validate.js +69 -0
  113. package/package.json +10 -10
  114. package/src/codegen/package.ts +2 -2
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Alertmanager entities: `Route`, `Receiver`, `InhibitRule`, `TimeInterval`
3
+ * and `AlertmanagerSettings`, which together serialize to `alertmanager.yml`.
4
+ *
5
+ * A route names its receiver and its time intervals either by the declared
6
+ * entity, which keeps the reference checked by TypeScript, or by name string
7
+ * for one declared somewhere chant can't see. PROM201 and PROM204 catch a
8
+ * string that names nothing declared.
9
+ *
10
+ * The routing tree is built from `Route`s: the root is the one route no other
11
+ * route lists as a child. Child routes may be `Route` entities or plain
12
+ * objects.
13
+ */
14
+ import { createResource } from "@intentius/chant/runtime";
15
+ export const RECEIVER_TYPE = "Prometheus::Alertmanager::Receiver";
16
+ export const ROUTE_TYPE = "Prometheus::Alertmanager::Route";
17
+ export const INHIBIT_RULE_TYPE = "Prometheus::Alertmanager::InhibitRule";
18
+ export const TIME_INTERVAL_TYPE = "Prometheus::Alertmanager::TimeInterval";
19
+ export const SETTINGS_TYPE = "Prometheus::Alertmanager::Settings";
20
+ function entityClass(entityType, className, extra) {
21
+ const Base = createResource(entityType, "prometheus", {});
22
+ const Cls = function (props) {
23
+ Base.call(this, (props ?? {}));
24
+ extra?.(this, props);
25
+ };
26
+ Object.defineProperty(Cls, "name", { value: className });
27
+ return Cls;
28
+ }
29
+ /**
30
+ * A notification receiver: webhook, email, Slack and PagerDuty integrations.
31
+ *
32
+ * Credentials (Slack `api_url`, PagerDuty `routing_key`, SMTP passwords)
33
+ * belong in the `*_file` fields; Alertmanager does not expand environment
34
+ * variables in its config, and PROM001 flags a literal.
35
+ */
36
+ export const Receiver = entityClass(RECEIVER_TYPE, "Receiver", (self, props) => {
37
+ Object.defineProperty(self, "receiverName", { value: props?.name, enumerable: false });
38
+ });
39
+ /** A named set of time periods a route can mute or activate on. */
40
+ export const TimeInterval = entityClass(TIME_INTERVAL_TYPE, "TimeInterval", (self, props) => {
41
+ Object.defineProperty(self, "intervalName", { value: props?.name, enumerable: false });
42
+ });
43
+ /** A node in the routing tree. Declare one root route; nest the rest under `routes`. */
44
+ export const Route = entityClass(ROUTE_TYPE, "Route");
45
+ /** Mutes alerts matching `target_matchers` while an alert matching `source_matchers` fires. */
46
+ export const InhibitRule = entityClass(INHIBIT_RULE_TYPE, "InhibitRule");
47
+ /** The `global:` block, `templates:` list and `tracing:` block. Optional; declare at most one. */
48
+ export const AlertmanagerSettings = entityClass(SETTINGS_TYPE, "AlertmanagerSettings");
49
+ // ── Guards ──────────────────────────────────────────────────────────
50
+ function isType(type) {
51
+ return (value) => typeof value === "object" && value !== null && value.entityType === type;
52
+ }
53
+ export const isReceiver = isType(RECEIVER_TYPE);
54
+ export const isRoute = isType(ROUTE_TYPE);
55
+ export const isInhibitRule = isType(INHIBIT_RULE_TYPE);
56
+ export const isTimeInterval = isType(TIME_INTERVAL_TYPE);
57
+ export const isAlertmanagerSettings = isType(SETTINGS_TYPE);
58
+ /** True for any of the Alertmanager entity kinds. */
59
+ export function isAlertmanagerEntity(value) {
60
+ return isReceiver(value) || isRoute(value) || isInhibitRule(value) || isTimeInterval(value) || isAlertmanagerSettings(value);
61
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The Alertmanager config APIs (#3371).
3
+ *
4
+ * | Kind | Config | Health |
5
+ * |---|---|---|
6
+ * | `alertmanager` | `/api/v2/status` `config.original` | the same answer: `cluster.status`, `versionInfo.version` |
7
+ * | `mimir`, `cortex` | `/api/v1/alerts` (YAML: `alertmanager_config`, `template_files`) | `<prefix>/api/v2/status`, best effort |
8
+ *
9
+ * A plain Alertmanager's `config.original` is not the file it loaded. It is
10
+ * the loaded config marshalled again (`Config.String()` in Alertmanager's
11
+ * `config/config.go`): secrets read `<secret>`, the global defaults are
12
+ * written out, and every receiver carries the settings it inherited from
13
+ * `global:`. ../import/alertmanager-live.ts takes those back out. Mimir and
14
+ * Cortex return the config as the tenant uploaded it.
15
+ *
16
+ * `setConfig` is for the ruler apply target (#3372): `POST /api/v1/alerts`,
17
+ * which Mimir and Cortex serve with `-alertmanager.enable-api`. A plain
18
+ * Alertmanager has no config write API.
19
+ */
20
+ import { dump } from "js-yaml";
21
+ import { loadPrometheusYaml } from "../import/parser.js";
22
+ import { PromApiError } from "./client.js";
23
+ function isObject(v) {
24
+ return typeof v === "object" && v !== null && !Array.isArray(v);
25
+ }
26
+ /** A bound Alertmanager: the transport and the target it reaches. */
27
+ export class AlertmanagerApi {
28
+ constructor(client, target) {
29
+ this.client = client;
30
+ this.target = target;
31
+ }
32
+ /** Where `/api/v2/status` is served. */
33
+ get statusPath() {
34
+ return this.target.kind === "alertmanager" ? "/api/v2/status" : `${this.target.alertmanagerPrefix}/api/v2/status`;
35
+ }
36
+ /** The status answer, read once per client; undefined on 404. */
37
+ status() {
38
+ return this.client.once("status", () => this.client.getJson(this.statusPath));
39
+ }
40
+ /** The loaded config; undefined when there is none (Mimir answers 404 for a tenant with no config). */
41
+ async readConfig() {
42
+ return this.client.once("config", async () => {
43
+ if (this.target.kind === "alertmanager") {
44
+ const status = await this.status();
45
+ if (status === undefined)
46
+ return undefined;
47
+ const original = isObject(status.config) ? status.config.original : undefined;
48
+ if (typeof original !== "string")
49
+ throw new PromApiError(502, "GET", this.statusPath, "the status has no config.original");
50
+ return { text: original, address: this.statusPath, remarshalled: true };
51
+ }
52
+ const path = "/api/v1/alerts";
53
+ const text = await this.client.getText(path);
54
+ if (text === undefined)
55
+ return undefined;
56
+ const doc = loadPrometheusYaml(text);
57
+ if (!isObject(doc) || typeof doc.alertmanager_config !== "string") {
58
+ throw new PromApiError(502, "GET", path, "the answer has no alertmanager_config");
59
+ }
60
+ const templateFiles = isObject(doc.template_files)
61
+ ? Object.fromEntries(Object.entries(doc.template_files).map(([k, v]) => [k, String(v)]))
62
+ : undefined;
63
+ return {
64
+ text: doc.alertmanager_config,
65
+ address: path,
66
+ remarshalled: false,
67
+ ...(templateFiles && Object.keys(templateFiles).length > 0 ? { templateFiles } : {}),
68
+ };
69
+ });
70
+ }
71
+ /** Cluster status and version. Never throws: a failed read is no health, since health is an attribute, not presence. */
72
+ async health() {
73
+ let status;
74
+ try {
75
+ status = await this.status();
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ if (!status)
81
+ return undefined;
82
+ const cluster = isObject(status.cluster) ? status.cluster : {};
83
+ const version = isObject(status.versionInfo) ? status.versionInfo.version : undefined;
84
+ return {
85
+ ...(typeof cluster.status === "string" ? { cluster: cluster.status } : {}),
86
+ ...(Array.isArray(cluster.peers) ? { peers: cluster.peers.length } : {}),
87
+ ...(typeof version === "string" ? { version } : {}),
88
+ };
89
+ }
90
+ /** Upload a config (Mimir and Cortex, `POST /api/v1/alerts`, 201). */
91
+ async setConfig(config, templateFiles = {}) {
92
+ if (this.target.kind === "alertmanager") {
93
+ throw new Error(`a plain Alertmanager (${this.target.source}) has no config API; it reads alertmanager.yml from disk`);
94
+ }
95
+ const body = dump({ template_files: templateFiles, alertmanager_config: config }, { lineWidth: -1, noRefs: true });
96
+ await this.client.send("POST", "/api/v1/alerts", { text: body, contentType: "application/yaml" });
97
+ }
98
+ }
@@ -0,0 +1,306 @@
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
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
+ import { basename, dirname, join } from "node:path";
27
+ import { applyResult, } from "@intentius/chant/apply";
28
+ import { loadPrometheusYaml } from "../import/parser.js";
29
+ import { declaredNamespaces, isUnresolvedTarget, namespaceOfGroup } from "../config.js";
30
+ import { bindEndpoints, classifyPromFailure } from "./bind.js";
31
+ import { PromApiError } from "./client.js";
32
+ function isObject(v) {
33
+ return typeof v === "object" && v !== null && !Array.isArray(v);
34
+ }
35
+ /** Read the build: the rule groups, and an Alertmanager config (the primary itself, or `alertmanager.yml` beside it). */
36
+ export function readBuilt(buildPath) {
37
+ const text = readFileSync(buildPath, "utf8");
38
+ const doc = loadPrometheusYaml(text);
39
+ const out = { groups: [] };
40
+ if (isObject(doc) && Array.isArray(doc.groups)) {
41
+ out.groups = doc.groups.filter((g) => isObject(g) && typeof g.name === "string");
42
+ }
43
+ else if (isObject(doc) && ("route" in doc || "receivers" in doc)) {
44
+ out.alertmanager = text;
45
+ }
46
+ const sibling = join(dirname(buildPath), "alertmanager.yml");
47
+ if (out.alertmanager === undefined && basename(buildPath) !== "alertmanager.yml" && existsSync(sibling)) {
48
+ out.alertmanager = readFileSync(sibling, "utf8");
49
+ }
50
+ return out;
51
+ }
52
+ /**
53
+ * True when every key the declared value carries is in the live one with an
54
+ * equal value. A ruler adds fields of its own to a group it returns
55
+ * (`source_tenants`, say), and those are not a difference.
56
+ */
57
+ export function covers(declared, live) {
58
+ if (Array.isArray(declared)) {
59
+ return Array.isArray(live) && declared.length === live.length && declared.every((d, i) => covers(d, live[i]));
60
+ }
61
+ if (isObject(declared)) {
62
+ return isObject(live) && Object.entries(declared).every(([k, v]) => covers(v, live[k]));
63
+ }
64
+ return declared === live;
65
+ }
66
+ /** The snapshot file an apply to `environment` writes beside the build file. */
67
+ export function snapshotPath(buildPath, environment) {
68
+ return join(dirname(buildPath), `.chant-ruler-previous.${environment.replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
69
+ }
70
+ async function planGroups(ruler, built, prune) {
71
+ const target = ruler.target;
72
+ const plan = { entries: [], writes: [], deletes: [], notAttempted: [] };
73
+ const declaredNs = declaredNamespaces(target);
74
+ // Read only the declared namespaces. A namespace the project does not
75
+ // declare is never listed, so nothing below can reach it.
76
+ const live = new Map();
77
+ for (const ns of declaredNs)
78
+ live.set(ns, (await ruler.readNamespace(ns)) ?? []);
79
+ const declaredByNs = new Map();
80
+ for (const group of built.groups) {
81
+ const ns = namespaceOfGroup(target, group.name);
82
+ if (ns === undefined || !declaredNs.includes(ns)) {
83
+ plan.notAttempted.push({
84
+ kind: "RuleGroup",
85
+ name: group.name,
86
+ reason: "no-binding",
87
+ detail: `no ruler namespace is declared for group "${group.name}": set prometheus.profiles.<env>.ruler.namespace or groupNamespaces`,
88
+ });
89
+ continue;
90
+ }
91
+ (declaredByNs.get(ns) ?? declaredByNs.set(ns, new Set()).get(ns)).add(group.name);
92
+ const previous = live.get(ns)?.find((g) => g.name === group.name);
93
+ const name = `${ns}/${group.name}`;
94
+ if (previous === undefined) {
95
+ plan.writes.push({ namespace: ns, group, action: "create" });
96
+ plan.entries.push({ kind: "RuleGroup", name, namespace: ns, group: group.name, action: "create" });
97
+ }
98
+ else if (!covers(group, previous)) {
99
+ plan.writes.push({ namespace: ns, group, action: "update", previous });
100
+ plan.entries.push({ kind: "RuleGroup", name, namespace: ns, group: group.name, action: "update" });
101
+ }
102
+ else {
103
+ plan.entries.push({ kind: "RuleGroup", name, namespace: ns, group: group.name, action: "unchanged" });
104
+ }
105
+ }
106
+ if (prune) {
107
+ for (const ns of declaredNs) {
108
+ for (const g of live.get(ns) ?? []) {
109
+ if (declaredByNs.get(ns)?.has(g.name))
110
+ continue;
111
+ plan.deletes.push({ namespace: ns, group: g });
112
+ plan.entries.push({ kind: "RuleGroup", name: `${ns}/${g.name}`, namespace: ns, group: g.name, action: "delete" });
113
+ }
114
+ }
115
+ }
116
+ return plan;
117
+ }
118
+ async function planAlertmanager(am, declared) {
119
+ const name = am.target.tenant ?? "alertmanager";
120
+ const current = await am.readConfig();
121
+ if (current === undefined)
122
+ return { entry: { kind: "AlertmanagerConfig", name, action: "create" } };
123
+ const same = JSON.stringify(loadPrometheusYaml(current.text)) === JSON.stringify(loadPrometheusYaml(declared));
124
+ return {
125
+ entry: { kind: "AlertmanagerConfig", name, action: same ? "unchanged" : "update" },
126
+ previous: { text: current.text, ...(current.templateFiles ? { templateFiles: current.templateFiles } : {}) },
127
+ };
128
+ }
129
+ /** What an apply would do, per declared namespace. Reads the ruler; writes nothing. */
130
+ export async function planRuler(args, deps = {}) {
131
+ const bound = await bindEndpoints({ ...deps, environment: args.environment });
132
+ if (isUnresolvedTarget(bound.ruler))
133
+ throw new Error(bound.ruler.detail);
134
+ const built = readBuilt(args.buildPath);
135
+ const plan = await planGroups(bound.ruler, built, args.prune ?? false);
136
+ const entries = [...plan.entries];
137
+ if (built.alertmanager !== undefined && !isUnresolvedTarget(bound.alertmanager) && bound.alertmanager.target.kind !== "alertmanager") {
138
+ entries.push((await planAlertmanager(bound.alertmanager, built.alertmanager)).entry);
139
+ }
140
+ return entries;
141
+ }
142
+ function allNotAttempted(built, reason, detail) {
143
+ return {
144
+ applied: [],
145
+ pruned: [],
146
+ notAttempted: [
147
+ ...built.groups.map((g) => ({ kind: "RuleGroup", name: g.name, reason, detail })),
148
+ ...(built.alertmanager !== undefined ? [{ kind: "AlertmanagerConfig", name: "alertmanager", reason, detail }] : []),
149
+ ],
150
+ };
151
+ }
152
+ /** Apply the build to the environment's ruler (and Alertmanager). */
153
+ export async function rulerApply(args, deps = {}) {
154
+ const environment = args.environment ?? "default";
155
+ const built = readBuilt(args.buildPath);
156
+ const bound = await bindEndpoints({ ...deps, environment: args.environment });
157
+ const out = { applied: [], pruned: [], notAttempted: [] };
158
+ let snapshot = { environment, restore: [], remove: [] };
159
+ let writeSnapshot = false;
160
+ if (built.groups.length > 0 || args.prune) {
161
+ if (isUnresolvedTarget(bound.ruler)) {
162
+ const failed = allNotAttempted({ groups: built.groups }, bound.ruler.reason, bound.ruler.detail);
163
+ out.notAttempted.push(...failed.notAttempted);
164
+ }
165
+ else if (bound.ruler.target.kind === "prometheus") {
166
+ const failed = allNotAttempted({ groups: built.groups }, "unsupported-kind", `a plain Prometheus (${bound.ruler.target.source}) has no rule group API`);
167
+ out.notAttempted.push(...failed.notAttempted);
168
+ }
169
+ else {
170
+ const ruler = bound.ruler;
171
+ try {
172
+ const plan = await planGroups(ruler, built, args.prune ?? false);
173
+ out.notAttempted.push(...plan.notAttempted);
174
+ snapshot.restore = [
175
+ ...plan.writes.filter((w) => w.previous).map((w) => ({ namespace: w.namespace, group: w.previous })),
176
+ ...plan.deletes.map((d) => ({ namespace: d.namespace, group: d.group })),
177
+ ];
178
+ snapshot.remove = plan.writes.filter((w) => w.action === "create").map((w) => ({ namespace: w.namespace, name: w.group.name }));
179
+ writeSnapshot = plan.writes.length > 0 || plan.deletes.length > 0;
180
+ if (writeSnapshot)
181
+ saveSnapshot(args.buildPath, snapshot);
182
+ for (const e of plan.entries) {
183
+ if (e.action === "unchanged")
184
+ out.applied.push({ kind: e.kind, name: e.name, action: "unchanged" });
185
+ }
186
+ for (const w of plan.writes) {
187
+ const name = `${w.namespace}/${w.group.name}`;
188
+ try {
189
+ await ruler.setGroup(w.namespace, w.group);
190
+ out.applied.push({ kind: "RuleGroup", name, action: w.action === "create" ? "created" : "updated" });
191
+ }
192
+ catch (err) {
193
+ out.notAttempted.push({ kind: "RuleGroup", name, ...failure(err) });
194
+ }
195
+ }
196
+ for (const d of plan.deletes) {
197
+ const name = `${d.namespace}/${d.group.name}`;
198
+ try {
199
+ await ruler.deleteGroup(d.namespace, d.group.name);
200
+ out.pruned.push({ kind: "RuleGroup", name, deleted: true });
201
+ }
202
+ catch (err) {
203
+ if (err instanceof PromApiError && err.status === 404)
204
+ out.pruned.push({ kind: "RuleGroup", name, deleted: false });
205
+ else
206
+ out.notAttempted.push({ kind: "RuleGroup", name, ...failure(err) });
207
+ }
208
+ }
209
+ }
210
+ catch (err) {
211
+ const f = failure(err);
212
+ out.notAttempted.push(...built.groups.map((g) => ({ kind: "RuleGroup", name: g.name, ...f })));
213
+ }
214
+ }
215
+ }
216
+ if (built.alertmanager !== undefined) {
217
+ const amName = isUnresolvedTarget(bound.alertmanager) ? "alertmanager" : (bound.alertmanager.target.tenant ?? "alertmanager");
218
+ if (isUnresolvedTarget(bound.alertmanager)) {
219
+ out.notAttempted.push({ kind: "AlertmanagerConfig", name: amName, reason: bound.alertmanager.reason, detail: bound.alertmanager.detail });
220
+ }
221
+ else if (bound.alertmanager.target.kind === "alertmanager") {
222
+ out.notAttempted.push({
223
+ kind: "AlertmanagerConfig",
224
+ name: amName,
225
+ reason: "unsupported-kind",
226
+ detail: `a plain Alertmanager (${bound.alertmanager.target.source}) has no config API; only Mimir and Cortex take an upload`,
227
+ });
228
+ }
229
+ else {
230
+ try {
231
+ const { entry, previous } = await planAlertmanager(bound.alertmanager, built.alertmanager);
232
+ if (entry.action === "unchanged") {
233
+ out.applied.push({ kind: entry.kind, name: entry.name, action: "unchanged" });
234
+ }
235
+ else {
236
+ if (previous) {
237
+ snapshot = { ...snapshot, alertmanager: previous };
238
+ }
239
+ saveSnapshot(args.buildPath, snapshot);
240
+ await bound.alertmanager.setConfig(built.alertmanager);
241
+ out.applied.push({ kind: entry.kind, name: entry.name, action: entry.action === "create" ? "created" : "updated" });
242
+ }
243
+ }
244
+ catch (err) {
245
+ out.notAttempted.push({ kind: "AlertmanagerConfig", name: amName, ...failure(err) });
246
+ }
247
+ }
248
+ }
249
+ return out;
250
+ }
251
+ function failure(err) {
252
+ const { reason, detail } = classifyPromFailure(err);
253
+ return { reason: reason === "no-credentials" || reason === "no-binding" ? reason : "dependency-failed", detail };
254
+ }
255
+ function saveSnapshot(buildPath, snapshot) {
256
+ const path = snapshotPath(buildPath, snapshot.environment);
257
+ mkdirSync(dirname(path), { recursive: true });
258
+ writeFileSync(path, JSON.stringify(snapshot, null, 2));
259
+ }
260
+ /**
261
+ * Re-apply the previous group set: put back every group the last apply
262
+ * replaced or deleted, and delete the ones it created. Touches only the
263
+ * groups the snapshot names, so a namespace the project does not declare is
264
+ * as untouched here as in the apply.
265
+ */
266
+ export async function rulerRollback(args, deps = {}) {
267
+ const environment = args.environment ?? "default";
268
+ const path = snapshotPath(args.buildPath, environment);
269
+ const outcome = { restored: 0, removed: 0, alertmanagerRestored: false, hadSnapshot: existsSync(path) };
270
+ if (!outcome.hadSnapshot)
271
+ return outcome;
272
+ const snapshot = JSON.parse(readFileSync(path, "utf8"));
273
+ const bound = await bindEndpoints({ ...deps, environment: args.environment });
274
+ if (snapshot.restore.length > 0 || snapshot.remove.length > 0) {
275
+ if (isUnresolvedTarget(bound.ruler))
276
+ throw new Error(bound.ruler.detail);
277
+ const declared = declaredNamespaces(bound.ruler.target);
278
+ for (const r of snapshot.restore) {
279
+ if (!declared.includes(r.namespace))
280
+ continue;
281
+ await bound.ruler.setGroup(r.namespace, r.group);
282
+ outcome.restored++;
283
+ }
284
+ for (const r of snapshot.remove) {
285
+ if (!declared.includes(r.namespace))
286
+ continue;
287
+ try {
288
+ await bound.ruler.deleteGroup(r.namespace, r.name);
289
+ outcome.removed++;
290
+ }
291
+ catch (err) {
292
+ if (!(err instanceof PromApiError && err.status === 404))
293
+ throw err;
294
+ }
295
+ }
296
+ }
297
+ if (snapshot.alertmanager !== undefined && !isUnresolvedTarget(bound.alertmanager) && bound.alertmanager.target.kind !== "alertmanager") {
298
+ await bound.alertmanager.setConfig(snapshot.alertmanager.text, snapshot.alertmanager.templateFiles ?? {});
299
+ outcome.alertmanagerRestored = true;
300
+ }
301
+ return outcome;
302
+ }
303
+ /** The outcome as core's apply envelope (#1446). */
304
+ export function toApplyResult(outcome) {
305
+ return applyResult(outcome.applied.map((a) => ({ kind: a.kind, name: a.name, action: a.action })), outcome.pruned, outcome.notAttempted);
306
+ }
@@ -0,0 +1,66 @@
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
+ import { PromApiError, PromClient, promHttp } from "./client.js";
10
+ import { AlertmanagerApi } from "./alertmanager.js";
11
+ import { RulerApi } from "./ruler.js";
12
+ import { isUnresolvedTarget, resolveAlertmanagerTarget, resolveRulerTarget } from "../config.js";
13
+ /** A target that could not be resolved. */
14
+ export class PromBindingError extends Error {
15
+ constructor(unresolved) {
16
+ super(unresolved.detail);
17
+ this.unresolved = unresolved;
18
+ this.name = "PromBindingError";
19
+ }
20
+ }
21
+ async function loadConfig(cwd) {
22
+ try {
23
+ const { loadChantConfig } = await import("@intentius/chant/config");
24
+ return (await loadChantConfig(cwd)).config;
25
+ }
26
+ catch {
27
+ // No project config (an ad-hoc import): the environment variables still bind.
28
+ return undefined;
29
+ }
30
+ }
31
+ /** Resolve both endpoints for an environment. Never throws for a missing binding; each side says why it is unbound. */
32
+ export async function bindEndpoints(options = {}) {
33
+ const config = options.config ?? (await loadConfig(options.cwd ?? process.cwd()));
34
+ const input = { environment: options.environment, config, env: options.env };
35
+ const r = resolveRulerTarget(input);
36
+ const a = resolveAlertmanagerTarget(input);
37
+ return {
38
+ ruler: isUnresolvedTarget(r) ? r : new RulerApi(new PromClient(options.http?.ruler ?? promHttp(r), r), r),
39
+ alertmanager: isUnresolvedTarget(a) ? a : new AlertmanagerApi(new PromClient(options.http?.alertmanager ?? promHttp(a), a), a),
40
+ };
41
+ }
42
+ /** The ruler alone, for a caller (the apply target) that needs it bound. Throws {@link PromBindingError} when there is none. */
43
+ export async function bindRuler(options = {}) {
44
+ const { ruler } = await bindEndpoints(options);
45
+ if (isUnresolvedTarget(ruler))
46
+ throw new PromBindingError(ruler);
47
+ return ruler;
48
+ }
49
+ /** The Alertmanager alone. Throws {@link PromBindingError} when there is none. */
50
+ export async function bindAlertmanager(options = {}) {
51
+ const { alertmanager } = await bindEndpoints(options);
52
+ if (isUnresolvedTarget(alertmanager))
53
+ throw new PromBindingError(alertmanager);
54
+ return alertmanager;
55
+ }
56
+ /** What a failed bind or read means, in the observation vocabulary. */
57
+ export function classifyPromFailure(err) {
58
+ if (err instanceof PromBindingError)
59
+ return { reason: err.unresolved.reason, detail: err.unresolved.detail };
60
+ if (err instanceof PromApiError) {
61
+ return err.verdict === "refused"
62
+ ? { reason: "no-credentials", detail: `${err.message} (the credentials were refused)` }
63
+ : { reason: "read-failed", detail: err.message };
64
+ }
65
+ return { reason: "read-failed", detail: err instanceof Error ? err.message.split("\n")[0] : String(err) };
66
+ }
@@ -0,0 +1,110 @@
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
+ function authHeader(auth) {
20
+ if (!auth)
21
+ return {};
22
+ if ("token" in auth)
23
+ return { authorization: `Bearer ${auth.token}` };
24
+ return { authorization: `Basic ${Buffer.from(`${auth.user}:${auth.password}`).toString("base64")}` };
25
+ }
26
+ /** The default transport: `fetch` against the target, with the tenant header and credentials on every request. */
27
+ export function promHttp(target, fetchImpl = fetch) {
28
+ const base = target.url.replace(/\/+$/, "");
29
+ const headers = {
30
+ accept: "application/json, application/yaml;q=0.9, */*;q=0.1",
31
+ ...authHeader(target.auth),
32
+ ...(target.tenant ? { "x-scope-orgid": target.tenant } : {}),
33
+ };
34
+ return async (method, path, body) => {
35
+ const res = await fetchImpl(`${base}${path}`, {
36
+ method,
37
+ headers: body === undefined ? headers : { ...headers, "content-type": body.contentType },
38
+ ...(body === undefined ? {} : { body: body.text }),
39
+ });
40
+ return { status: res.status, text: await res.text() };
41
+ };
42
+ }
43
+ export function statusVerdict(status) {
44
+ if (status >= 200 && status < 300)
45
+ return "ok";
46
+ if (status === 404)
47
+ return "not-found";
48
+ if (status === 401 || status === 403)
49
+ return "refused";
50
+ return "failed";
51
+ }
52
+ /** A non-2xx answer, with enough to classify it and to say where it came from. */
53
+ export class PromApiError extends Error {
54
+ constructor(status, method, path, body) {
55
+ const detail = body.trim().split("\n")[0].slice(0, 200);
56
+ super(`${method} ${path} returned ${status}${detail ? `: ${detail}` : ""}`);
57
+ this.status = status;
58
+ this.method = method;
59
+ this.path = path;
60
+ this.name = "PromApiError";
61
+ this.verdict = statusVerdict(status);
62
+ }
63
+ }
64
+ /** A bound client: the transport, the target it reaches, and a per-client memo so N reads share one lookup. */
65
+ export class PromClient {
66
+ constructor(http, target) {
67
+ this.http = http;
68
+ this.target = target;
69
+ this.memo = new Map();
70
+ }
71
+ /** GET, returning the body text; undefined on 404; throws {@link PromApiError} on any other non-2xx. */
72
+ async getText(path) {
73
+ const res = await this.http("GET", path);
74
+ const verdict = statusVerdict(res.status);
75
+ if (verdict === "ok")
76
+ return res.text;
77
+ if (verdict === "not-found")
78
+ return undefined;
79
+ throw new PromApiError(res.status, "GET", path, res.text);
80
+ }
81
+ /** GET a JSON body; undefined on 404. A 2xx body that is not JSON is a failed read. */
82
+ async getJson(path) {
83
+ const text = await this.getText(path);
84
+ if (text === undefined)
85
+ return undefined;
86
+ try {
87
+ return JSON.parse(text);
88
+ }
89
+ catch {
90
+ throw new PromApiError(502, "GET", path, `the answer is not JSON: ${text.slice(0, 80)}`);
91
+ }
92
+ }
93
+ /** A write. Resolves on a 2xx; throws {@link PromApiError} otherwise, a 404 included. */
94
+ async send(method, path, body) {
95
+ const res = await this.http(method, path, body);
96
+ if (statusVerdict(res.status) !== "ok")
97
+ throw new PromApiError(res.status, method, path, res.text);
98
+ return res;
99
+ }
100
+ /** Run `fn` once per key for this client's lifetime; concurrent callers share the promise. A failure is not cached. */
101
+ once(key, fn) {
102
+ let p = this.memo.get(key);
103
+ if (!p) {
104
+ p = fn();
105
+ this.memo.set(key, p);
106
+ p.catch(() => this.memo.delete(key));
107
+ }
108
+ return p;
109
+ }
110
+ }