@homeflare/alchemy 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/grafana/alert-rule-group-drop-warning.d.ts +25 -0
  2. package/dist/grafana/alert-rule-group-drop-warning.d.ts.map +1 -0
  3. package/dist/grafana/alert-rule-group-fixtures.d.ts +73 -0
  4. package/dist/grafana/alert-rule-group-fixtures.d.ts.map +1 -0
  5. package/dist/grafana/alert-rule-group-model.d.ts +28 -0
  6. package/dist/grafana/alert-rule-group-model.d.ts.map +1 -0
  7. package/dist/grafana/alert-rule-group.d.ts +60 -0
  8. package/dist/grafana/alert-rule-group.d.ts.map +1 -0
  9. package/dist/grafana/index.d.ts +6 -4
  10. package/dist/grafana/index.d.ts.map +1 -1
  11. package/dist/grafana/index.js +355 -139
  12. package/dist/grafana/index.js.map +14 -8
  13. package/dist/grafana/notification-policy-drop-warning.d.ts +27 -0
  14. package/dist/grafana/notification-policy-drop-warning.d.ts.map +1 -0
  15. package/dist/grafana/notification-policy-fixtures.d.ts +34 -0
  16. package/dist/grafana/notification-policy-fixtures.d.ts.map +1 -0
  17. package/dist/grafana/notification-policy-model.d.ts +53 -0
  18. package/dist/grafana/notification-policy-model.d.ts.map +1 -0
  19. package/dist/grafana/notification-policy.d.ts +57 -0
  20. package/dist/grafana/notification-policy.d.ts.map +1 -0
  21. package/dist/grafana/providers.d.ts +1 -1
  22. package/dist/grafana/providers.d.ts.map +1 -1
  23. package/dist/proxmox/index.js +349 -287
  24. package/dist/proxmox/index.js.map +8 -7
  25. package/dist/proxmox/network-apply-read.d.ts +12 -0
  26. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  27. package/dist/proxmox/network-apply.d.ts +2 -0
  28. package/dist/proxmox/network-apply.d.ts.map +1 -1
  29. package/dist/proxmox/node-network-form.d.ts +86 -54
  30. package/dist/proxmox/node-network-form.d.ts.map +1 -1
  31. package/dist/proxmox/node-network-wire.d.ts +100 -0
  32. package/dist/proxmox/node-network-wire.d.ts.map +1 -0
  33. package/dist/proxmox/node-network.d.ts +21 -106
  34. package/dist/proxmox/node-network.d.ts.map +1 -1
  35. package/docs/grafana-alerting-rules.md +143 -0
  36. package/docs/grafana-alerting.md +7 -4
  37. package/docs/grafana-notification-policy.md +127 -0
  38. package/docs/grafana.md +12 -6
  39. package/docs/upstream-conformance.md +30 -4
  40. package/package.json +1 -1
  41. package/src/grafana/alert-rule-group-drop-warning.test.ts +107 -0
  42. package/src/grafana/alert-rule-group-drop-warning.ts +41 -0
  43. package/src/grafana/alert-rule-group-fixtures.ts +46 -0
  44. package/src/grafana/alert-rule-group-model.test.ts +75 -0
  45. package/src/grafana/alert-rule-group-model.ts +66 -0
  46. package/src/grafana/alert-rule-group-provenance.test.ts +116 -0
  47. package/src/grafana/alert-rule-group.test.ts +164 -0
  48. package/src/grafana/alert-rule-group.ts +231 -0
  49. package/src/grafana/index.ts +18 -4
  50. package/src/grafana/notification-policy-drop-warning.test.ts +163 -0
  51. package/src/grafana/notification-policy-drop-warning.ts +60 -0
  52. package/src/grafana/notification-policy-fixtures.ts +27 -0
  53. package/src/grafana/notification-policy-model.test.ts +104 -0
  54. package/src/grafana/notification-policy-model.ts +100 -0
  55. package/src/grafana/notification-policy-refusals.test.ts +119 -0
  56. package/src/grafana/notification-policy.test.ts +86 -0
  57. package/src/grafana/notification-policy.ts +191 -0
  58. package/src/grafana/providers.ts +4 -0
  59. package/src/proxmox/constraints-host-forms.test.ts +2 -2
  60. package/src/proxmox/network-apply-read.ts +12 -0
  61. package/src/proxmox/network-apply.ts +2 -0
  62. package/src/proxmox/node-network-form.ts +93 -99
  63. package/src/proxmox/node-network-read-failure.test.ts +206 -0
  64. package/src/proxmox/node-network-wire.ts +239 -0
  65. package/src/proxmox/node-network.ts +155 -167
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Pure functions over one declared alert rule within a `Grafana.AlertRuleGroup` — no SDK call, no
3
+ * Effect. Split out of `alert-rule-group.ts` the same way `dashboard-model.ts` is split out of
4
+ * `dashboard.ts`: keeps that file under the house's 250-line cap, and lets the volatile-field
5
+ * normalization be exercised directly in a test without a fake Grafana in the way.
6
+ */
7
+ import type * as grafana from '@distilled.cloud/grafana';
8
+
9
+ /** One declared rule — the SDK's own `ProvisionedAlertRuleInput` shape, minus the three fields
10
+ * this resource derives from the GROUP itself (never declared per-rule) and `uid` widened from
11
+ * optional to required — see alert-rule-group.ts's file header for the uid-required doctrine. */
12
+ export type AlertRuleInput = Omit<
13
+ grafana.ProvisionedAlertRuleInput,
14
+ 'folderUID' | 'orgID' | 'ruleGroup' | 'uid'
15
+ > & { uid: string };
16
+
17
+ /** Grafana injects/reassigns these on every write; a declared rule never sets them meaningfully.
18
+ * MEASURED against `ProvisionedAlertRule` (services/grafana.ts): `id` is the internal numeric row
19
+ * id, `updated` a server-set timestamp — neither exists on `ProvisionedAlertRuleInput` (the WRITE
20
+ * shape) at all, only on the READ shape, confirming both are Grafana-managed, not declarable.
21
+ * `provenance` is likewise read-only, but handled separately by `alerting-provenance.ts`'s
22
+ * refusal rather than compared here: an object this family just wrote reads back with
23
+ * `provenance: "api"`, which the DECLARATION never sets, so comparing it here would show a false
24
+ * `update` on every rule this family itself owns — the refusal already gives it the attention it
25
+ * needs, before this comparison ever runs. */
26
+ const RULE_VOLATILE_FIELDS = ['id', 'updated', 'provenance'] as const;
27
+
28
+ /** `folderUID`/`ruleGroup`/`orgID` are forced to the group's own values (mirrors
29
+ * `dashboard-model.ts`'s `normalizeModel` forcing `uid`) — a rule's declaration never repeats its
30
+ * own group/folder, and a rule pasted from Grafana's own export always embeds whatever it read at
31
+ * export time, which forcing to canonical values makes comparable regardless.
32
+ * ⚠️ `orgID` DEFAULTS TO `1` — NOT MEASURED against a multi-org instance. Every Grafana instance
33
+ * this house runs is single-org (grafana.md's own credentials section has no org-selecting
34
+ * example); `GrafanaTarget.orgId` exists for a future multi-org target but a rule's own spec
35
+ * functions have no access to it (unlike credentials, it is not ambient context here). A
36
+ * multi-org declaration would need an explicit `orgID` prop added to `AlertRuleInput` — not
37
+ * attempted here since no live target needs it yet. */
38
+ export const normalizeRule = (
39
+ rule: Record<string, unknown>,
40
+ folderUid: string,
41
+ group: string,
42
+ ): Record<string, unknown> => {
43
+ const out: Record<string, unknown> = {};
44
+ for (const [key, value] of Object.entries(rule)) {
45
+ if (key === 'folderUID' || key === 'ruleGroup' || key === 'orgID') continue;
46
+ if ((RULE_VOLATILE_FIELDS as readonly string[]).includes(key)) continue;
47
+ out[key] = value;
48
+ }
49
+ out.folderUID = folderUid;
50
+ out.ruleGroup = group;
51
+ out.orgID = 1;
52
+ return out;
53
+ };
54
+
55
+ /** The full wire body for one rule on `routePutAlertRuleGroup` — the group-derived fields filled
56
+ * in, `orgID` defaulted per the note above. */
57
+ export const toWireRule = (
58
+ rule: AlertRuleInput,
59
+ folderUid: string,
60
+ group: string,
61
+ ): grafana.ProvisionedAlertRuleInput => ({
62
+ ...rule,
63
+ folderUID: folderUid,
64
+ orgID: 1,
65
+ ruleGroup: group,
66
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `Grafana.AlertRuleGroup`'s per-rule provenance refusal — explicit-foreign (`"file"` on one
3
+ * rule), missing (a rule whose response omits the field, failing closed the same way every other
4
+ * resource in this family does), an EXPLICIT `""` being writable, and an empty group having
5
+ * nothing to refuse on. Split out of `alert-rule-group.test.ts` to keep each file under the
6
+ * house's 250-line cap.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import * as Effect from 'effect/Effect';
10
+ import { GrafanaProvisionedObjectError } from './alerting-provenance.ts';
11
+ import { PATH, liveJson, liveRule, props } from './alert-rule-group-fixtures.ts';
12
+ import { spec } from './alert-rule-group.ts';
13
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
14
+ import { grafanaOperations } from './resource.ts';
15
+
16
+ describe('a foreign-provenance rule refuses the whole group', () => {
17
+ test('one file-provisioned rule among several refuses update and sends no PUT', async () => {
18
+ const mixed = {
19
+ ...liveJson,
20
+ rules: [liveRule, { ...liveRule, uid: 'other', provenance: 'file' }],
21
+ };
22
+ const fake = fakeGrafana((method, url) =>
23
+ method === 'GET' && url.pathname === PATH
24
+ ? Response.json(mixed)
25
+ : fakeFailure(500, 'no write should be attempted'),
26
+ );
27
+ const failure = await Effect.runPromise(
28
+ Effect.flip(
29
+ grafanaOperations(spec)
30
+ .reconcile({ ...props, interval: 120 })
31
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
32
+ ),
33
+ );
34
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
35
+ expect(failure.message).toContain('file');
36
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
37
+ });
38
+
39
+ test('a file-provisioned group refuses destroy and sends no DELETE', async () => {
40
+ const fileProvisioned = { ...liveJson, rules: [{ ...liveRule, provenance: 'file' }] };
41
+ const fake = fakeGrafana((method, url) =>
42
+ method === 'GET' && url.pathname === PATH
43
+ ? Response.json(fileProvisioned)
44
+ : fakeFailure(500, 'no DELETE should be attempted'),
45
+ );
46
+ const failure = await Effect.runPromise(
47
+ Effect.flip(
48
+ grafanaOperations(spec)
49
+ .destroy(props)
50
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
51
+ ),
52
+ );
53
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
54
+ expect(fake.seen).toEqual([{ method: 'GET', path: PATH }]);
55
+ });
56
+ });
57
+
58
+ describe('missing provenance on a rule fails closed', () => {
59
+ // ⛔ SAME REGRESSION AN ADVERSARIAL REVIEW OF kit PR 250 FOUND — see contact-point-provenance.
60
+ // test.ts's own copy of this block for the full account.
61
+ const noProvenanceRule: Record<string, unknown> = { ...liveRule };
62
+ delete noProvenanceRule.provenance;
63
+
64
+ test('a response where a rule omits provenance entirely refuses update', async () => {
65
+ const missing = { ...liveJson, rules: [noProvenanceRule] };
66
+ const fake = fakeGrafana((method, url) =>
67
+ method === 'GET' && url.pathname === PATH
68
+ ? Response.json(missing)
69
+ : fakeFailure(500, 'no write should be attempted'),
70
+ );
71
+ const failure = await Effect.runPromise(
72
+ Effect.flip(
73
+ grafanaOperations(spec)
74
+ .reconcile({ ...props, interval: 120 })
75
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
76
+ ),
77
+ );
78
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
79
+ expect(failure.message).toContain('did not report a provenance');
80
+ });
81
+
82
+ test('an EXPLICIT empty-string provenance (ProvenanceNone) is writable', async () => {
83
+ const explicitlyNone = { ...liveJson, rules: [{ ...liveRule, provenance: '' }] };
84
+ const fake = fakeGrafana((method, url) => {
85
+ if (method === 'GET' && url.pathname === PATH) return Response.json(explicitlyNone);
86
+ if (method === 'PUT' && url.pathname === PATH) return Response.json(explicitlyNone);
87
+ return fakeFailure(500, 'unexpected request');
88
+ });
89
+ await Effect.runPromise(
90
+ grafanaOperations(spec)
91
+ .reconcile({ ...props, interval: 120 })
92
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
93
+ );
94
+ expect(fake.seen.some((s) => s.method === 'PUT')).toBe(true);
95
+ });
96
+ });
97
+
98
+ describe('an empty live group has nothing to refuse on', () => {
99
+ test('a group with zero rules is never foreign, and destroy proceeds', async () => {
100
+ const empty = { ...liveJson, rules: [] };
101
+ const fake = fakeGrafana((method, url) => {
102
+ if (method === 'GET' && url.pathname === PATH) return Response.json(empty);
103
+ if (method === 'DELETE' && url.pathname === PATH) return Response.json({});
104
+ return fakeFailure(500, 'unexpected request');
105
+ });
106
+ await Effect.runPromise(
107
+ grafanaOperations(spec)
108
+ .destroy(props)
109
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
110
+ );
111
+ expect(fake.seen).toEqual([
112
+ { method: 'GET', path: PATH },
113
+ { method: 'DELETE', path: PATH },
114
+ ]);
115
+ });
116
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `Grafana.AlertRuleGroup` against a fake Grafana — the group-not-rule unit's create/update as one
3
+ * PUT, rule-order significance, volatile-field normalization, and the folder-doesn't-exist refusal.
4
+ * Provenance refusal tests: `alert-rule-group-provenance.test.ts` — split to keep each file under
5
+ * the house's 250-line cap. Mirrors `dashboard.test.ts`.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Effect from 'effect/Effect';
9
+ import { PATH, liveJson, props, ruleInput } from './alert-rule-group-fixtures.ts';
10
+ import { spec } from './alert-rule-group.ts';
11
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
12
+ import { grafanaOperations } from './resource.ts';
13
+
14
+ describe('spec.fetchLive', () => {
15
+ test('a live group decodes and normalizes into typed attributes', async () => {
16
+ const fake = fakeGrafana((method, url) =>
17
+ method === 'GET' && url.pathname === PATH ? Response.json(liveJson) : fakeFailure(404, 'x'),
18
+ );
19
+ const live = await Effect.runPromise(
20
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
21
+ );
22
+ expect(live !== undefined && spec.attributes(live, props)).toEqual({
23
+ folderUid: 'infra',
24
+ group: 'default',
25
+ interval: 60,
26
+ rules: [
27
+ {
28
+ condition: 'A',
29
+ data: [{ refId: 'A' }],
30
+ execErrState: 'Alerting',
31
+ folderUID: 'infra',
32
+ for: '5m',
33
+ noDataState: 'NoData',
34
+ orgID: 1,
35
+ ruleGroup: 'default',
36
+ title: 'High CPU',
37
+ uid: 'high-cpu',
38
+ },
39
+ ],
40
+ });
41
+ expect(fake.bodies[0]).toBeUndefined();
42
+ });
43
+
44
+ test('a 404 folds to undefined', async () => {
45
+ const fake = fakeGrafana(() => fakeFailure(404, 'rule group not found'));
46
+ const result = await Effect.runPromise(
47
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
48
+ );
49
+ expect(result).toBeUndefined();
50
+ });
51
+
52
+ test('a 403 propagates — never folded to absent', async () => {
53
+ const fake = fakeGrafana(() => fakeFailure(403, 'forbidden'));
54
+ const failure = await Effect.runPromise(
55
+ Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
56
+ );
57
+ expect(failure._tag).toBe('Forbidden');
58
+ });
59
+ });
60
+
61
+ describe('create', () => {
62
+ test('sends interval and rules with the group-derived fields filled in', async () => {
63
+ const fake = fakeGrafana((method, url) =>
64
+ method === 'PUT' && url.pathname === PATH ? Response.json(liveJson) : fakeFailure(404, 'x'),
65
+ );
66
+ await Effect.runPromise(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
67
+ expect(fake.bodies[0]).toEqual({
68
+ folderUid: 'infra',
69
+ interval: 60,
70
+ rules: [{ ...ruleInput, folderUID: 'infra', orgID: 1, ruleGroup: 'default' }],
71
+ title: 'default',
72
+ });
73
+ });
74
+
75
+ test('a nonexistent target folder fails as a typed BadRequest, never silently created', async () => {
76
+ // ⛔ THE POINT: this resource never checks folder existence itself and never calls
77
+ // `Grafana.Folder`'s create path — see the file header. `RoutePutAlertRuleGroupError` has no
78
+ // `NotFound` case; Grafana's own validation failure is a `BadRequest`, left uncaught here.
79
+ const fake = fakeGrafana(() => fakeFailure(400, 'folder does not exist'));
80
+ const failure = await Effect.runPromise(
81
+ Effect.flip(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
82
+ );
83
+ expect(failure._tag).toBe('BadRequest');
84
+ });
85
+ });
86
+
87
+ describe('reconcile — adopt semantics, rule order significant', () => {
88
+ test('declaring exactly what is live is a noop despite id/updated/provenance differing', async () => {
89
+ const fake = fakeGrafana((method, url) =>
90
+ method === 'GET' && url.pathname === PATH
91
+ ? Response.json(liveJson)
92
+ : fakeFailure(500, 'reconcile should not write when nothing changed'),
93
+ );
94
+ const after = await Effect.runPromise(
95
+ grafanaOperations(spec)
96
+ .reconcile(props)
97
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
98
+ );
99
+ expect(after.group).toBe('default');
100
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
101
+ });
102
+
103
+ test('two rules in a different order than live shows update — order is content, not cosmetic', async () => {
104
+ // ⛔ live is [low-disk, high-cpu]; declared below is [high-cpu, low-disk] — the SAME two rules,
105
+ // reversed. A looser "declared is a set of live's rules" comparison would call this a match;
106
+ // subset-match's array rule (position-significant) must not.
107
+ const secondRule = { ...ruleInput, title: 'Low Disk', uid: 'low-disk' };
108
+ const reordered = {
109
+ ...liveJson,
110
+ rules: [
111
+ { ...liveJson.rules[0], title: 'Low Disk', uid: 'low-disk' },
112
+ { ...liveJson.rules[0] },
113
+ ],
114
+ };
115
+ const fake = fakeGrafana((method, url) =>
116
+ method === 'GET' && url.pathname === PATH ? Response.json(reordered) : fakeFailure(500, 'x'),
117
+ );
118
+ const live = await Effect.runPromise(
119
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
120
+ );
121
+ const attrs = live !== undefined ? spec.attributes(live, props) : undefined;
122
+ const declaredReordered = { ...props, rules: [ruleInput, secondRule] };
123
+ expect(attrs !== undefined && spec.matches(attrs, declaredReordered)).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe('update', () => {
128
+ test('a content change updates via the same PUT, sending only declared fields', async () => {
129
+ const changed = { ...ruleInput, for: '10m' };
130
+ const fake = fakeGrafana((method, url) => {
131
+ if (method === 'GET' && url.pathname === PATH) return Response.json(liveJson);
132
+ if (method === 'PUT' && url.pathname === PATH) {
133
+ return Response.json({ ...liveJson, rules: [{ ...liveJson.rules[0], for: '10m' }] });
134
+ }
135
+ return fakeFailure(500, 'unexpected request');
136
+ });
137
+ await Effect.runPromise(
138
+ grafanaOperations(spec)
139
+ .reconcile({ ...props, rules: [changed] })
140
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
141
+ );
142
+ const put = fake.seen.findIndex((s) => s.method === 'PUT');
143
+ expect(fake.bodies[put]).toMatchObject({ rules: [{ for: '10m' }] });
144
+ });
145
+ });
146
+
147
+ describe('destroy', () => {
148
+ test('deletes a live, api-provenance group', async () => {
149
+ const fake = fakeGrafana((method, url) => {
150
+ if (method === 'GET' && url.pathname === PATH) return Response.json(liveJson);
151
+ if (method === 'DELETE' && url.pathname === PATH) return Response.json({});
152
+ return fakeFailure(500, 'unexpected request');
153
+ });
154
+ await Effect.runPromise(
155
+ grafanaOperations(spec)
156
+ .destroy(props)
157
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
158
+ );
159
+ expect(fake.seen).toEqual([
160
+ { method: 'GET', path: PATH },
161
+ { method: 'DELETE', path: PATH },
162
+ ]);
163
+ });
164
+ });
@@ -0,0 +1,231 @@
1
+ /**
2
+ * `Grafana.AlertRuleGroup` — one Grafana alert rule GROUP, keyed by `(folderUid, group)`.
3
+ *
4
+ * ★ GROUP, NOT PER-RULE — THE UNIT CHOICE, JUSTIFIED. MEASURED against the SDK's generated
5
+ * operations: `PUT /v1/provisioning/folder/{FolderUID}/rule-groups/{Group}`
6
+ * (`routePutAlertRuleGroup`) takes the evaluation `interval` AND the full ordered `rules[]`
7
+ * TOGETHER, replacing the whole group in one call (`RoutePutAlertRuleGroupRequest.rules:
8
+ * Array<ProvisionedAlertRuleInput>`) — the individual `routePostAlertRule`/`routePutAlertRule`
9
+ * operations have no `interval` field at all and cannot reorder rules within a group. Declaring
10
+ * rules one at a time (`Grafana.AlertRule`) would leave a group's evaluation interval and rule
11
+ * order — both real, load-bearing Grafana state — either undeclarable or racy across N
12
+ * independent resources with no shared source of truth for them. Grafana's own docs describe
13
+ * `X-Disable-Provenance` on the rule-group PUT as setting "the provenance for the rule group AND
14
+ * ALL ITS ALERT RULES" — further evidence the group, not the rule, is Grafana's own natural unit
15
+ * of ownership here. Mirrors Terraform's own `grafana_rule_group` resource, which takes the
16
+ * identical shape (a list of rules under one folder/name/interval).
17
+ *
18
+ * ★ `uid` IS REQUIRED ON EVERY DECLARED RULE — same doctrine as every other resource in this
19
+ * family (`AlertRuleInput` in `alert-rule-group-model.ts`). `ProvisionedAlertRuleInput.uid` is
20
+ * optional on the wire (Grafana assigns one if omitted), but locating a specific rule this
21
+ * resource didn't just create would then mean matching by title — the same ambiguity
22
+ * `resource.ts`'s header and netbox's `soleMatch` exist to catch everywhere else in this family.
23
+ *
24
+ * ⛔ RULE ORDER IS SIGNIFICANT — NEVER REORDERED. `subset-match.ts`'s array comparison is already
25
+ * position-significant (length, then element-wise) — this resource relies on that as-is; nothing
26
+ * here re-sorts `rules` before comparing. Declaring rules in a different order than what is live
27
+ * is a genuine content change (Grafana evaluates and displays a group's rules in array order),
28
+ * not a cosmetic one.
29
+ *
30
+ * ⛔ A FOREIGN-PROVENANCE GROUP REFUSES EVERY UPDATE AND DESTROY — `alerting-provenance.ts`. Unlike
31
+ * `ContactPoint`/`MessageTemplate`, `AlertRuleGroup` (the type `routeGetAlertRuleGroup` returns)
32
+ * carries NO top-level `provenance` field — MEASURED: `folderUid?`/`interval?`/`rules?`/`title?`
33
+ * only. Each RULE inside `rules[]` has its own (`ProvisionedAlertRule.provenance`), and Grafana's
34
+ * docs say a new rule's provenance "must match the provenance value configured for its rule
35
+ * group" — so this resource checks every live rule and refuses if ANY carries a foreign
36
+ * provenance, naming the first one found. An empty live group (no rules yet) has nothing to check
37
+ * and is never foreign. `create` is never at risk: it only runs when `fetchLive` already found
38
+ * nothing.
39
+ *
40
+ * ⛔ A DECLARED GROUP WHOSE FOLDER DOES NOT EXIST REFUSES — IT IS NEVER AUTO-CREATED. This resource
41
+ * makes no "does the folder exist" call of its own and does not depend on or invoke
42
+ * `Grafana.Folder`'s create path. MEASURED: `RoutePutAlertRuleGroupError` is
43
+ * `BadRequest | Forbidden | GrafanaOpError` — no `NotFound` case at all — so a nonexistent
44
+ * `folderUid` fails the PUT as a typed `BadRequest`, left uncaught and propagated (the house rule
45
+ * against folding a non-not-found error into absent), which fails the whole `reconcile` and stops
46
+ * the deploy. Declare a `Grafana.Folder` for it first — the two resources sharing one `folderUid`
47
+ * string is the dependency; auto-creating a folder from inside this resource would blur ownership
48
+ * the same way `provisioned.ts`'s "one owner per resource" principle already guards against
49
+ * elsewhere in this family. ⚠️ NOT RE-MEASURED against a live instance in this PR — inferred from
50
+ * the declared error union, the same evidentiary bar `folder.ts`'s own `version` note uses.
51
+ *
52
+ * ⛔ DELETE DEFAULTS TO `retain` — DELETING A GROUP DELETES EVERY RULE IN IT IN ONE CALL
53
+ * (`routeDeleteAlertRuleGroup`, whole-group DELETE). The same multi-object-cascade class
54
+ * `Grafana.Folder`/`openbao/mount.ts` already guard with `defaultRemovalPolicy: 'retain'` — a
55
+ * stack that wants the cascade opts in with `.pipe(RemovalPolicy.destroy())`; `destroy` below
56
+ * still implements the real DELETE in full either way (S11).
57
+ *
58
+ * ⚠️ `orgID` DEFAULTS TO `1` ON EVERY WRITTEN RULE — see `alert-rule-group-model.ts`'s own note;
59
+ * not measured against a multi-org instance, since none exists in this house yet.
60
+ *
61
+ * ⚠️ `X-Disable-Provenance` IS NEVER SET — same reasoning as `contact-point.ts`'s file header.
62
+ *
63
+ * ⚠️ A WHOLE-GROUP WRITE SILENTLY DROPS ANY LIVE RULE THE DECLARATION DOESN'T MENTION — flagged by
64
+ * an adversarial review of this PR. Unlike every other resource in this family, this one does
65
+ * not compare one object's fields; `update`/`diff` REPLACE an entire collection, so a live rule
66
+ * with no counterpart in `props.rules` (e.g. one a human just added in the Grafana UI, ordinary
67
+ * writable provenance — nothing for the refusal above to catch) is removed with no error and no
68
+ * distinct `Diff` action to report it under. This is accepted by design — the group is still the
69
+ * unit — but never SILENT: `alert-rule-group-drop-warning.ts`'s `warnOnDroppedRules` logs every
70
+ * dropped rule by title and uid, called from both the custom `diff` below (so `bun run plan`
71
+ * shows it before anything is written) and from `update` itself (so it is visible wherever
72
+ * `reconcile` runs directly too).
73
+ */
74
+ import { isResolved } from 'alchemy/Diff';
75
+ import type { Input } from 'alchemy/Input';
76
+ import { Resource } from 'alchemy';
77
+ import * as Provider from 'alchemy/Provider';
78
+ import * as grafana from '@distilled.cloud/grafana';
79
+ import * as Effect from 'effect/Effect';
80
+ import { warnOnDroppedRules } from './alert-rule-group-drop-warning.ts';
81
+ import { type AlertRuleInput, normalizeRule, toWireRule } from './alert-rule-group-model.ts';
82
+ import {
83
+ type GrafanaProvisionedObjectError,
84
+ isForeignProvenance,
85
+ refuseIfForeignProvenance,
86
+ } from './alerting-provenance.ts';
87
+ import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
88
+ import { declaredContentMatches } from './subset-match.ts';
89
+
90
+ export type { AlertRuleInput };
91
+
92
+ export interface AlertRuleGroupProps {
93
+ folderUid: string;
94
+ group: string;
95
+ /** Evaluation interval, in seconds. */
96
+ interval: number;
97
+ /** Order is significant — see the file header. */
98
+ rules: ReadonlyArray<AlertRuleInput>;
99
+ }
100
+
101
+ export interface AlertRuleGroupAttributes {
102
+ folderUid: string;
103
+ group: string;
104
+ interval: number;
105
+ rules: ReadonlyArray<Record<string, unknown>>;
106
+ }
107
+
108
+ export interface GrafanaAlertRuleGroup extends Resource<
109
+ 'Grafana.AlertRuleGroup',
110
+ AlertRuleGroupProps,
111
+ AlertRuleGroupAttributes,
112
+ never
113
+ > {}
114
+
115
+ export const GrafanaAlertRuleGroup = Resource<GrafanaAlertRuleGroup>('Grafana.AlertRuleGroup', {
116
+ defaultRemovalPolicy: 'retain',
117
+ });
118
+
119
+ /** The first live rule whose provenance this family may not write to, or `undefined` when every
120
+ * rule (including none at all) is writable. */
121
+ const findForeignRule = (
122
+ rules: ReadonlyArray<grafana.ProvisionedAlertRule> | undefined,
123
+ ): grafana.ProvisionedAlertRule | undefined =>
124
+ (rules ?? []).find((rule) => isForeignProvenance(rule.provenance));
125
+
126
+ const body = (props: AlertRuleGroupProps) => ({
127
+ folderUid: props.folderUid,
128
+ interval: props.interval,
129
+ rules: props.rules.map((rule) => toWireRule(rule, props.folderUid, props.group)),
130
+ title: props.group,
131
+ });
132
+
133
+ const refuseForForeignRule = (props: AlertRuleGroupProps, foreign: grafana.ProvisionedAlertRule) =>
134
+ refuseIfForeignProvenance(
135
+ 'Grafana.AlertRuleGroup',
136
+ `${props.folderUid}/${props.group}`,
137
+ foreign.provenance,
138
+ );
139
+
140
+ const declaredUids = (props: AlertRuleGroupProps): ReadonlySet<string> =>
141
+ new Set(props.rules.map((rule) => rule.uid));
142
+
143
+ export const spec: GrafanaSpec<
144
+ AlertRuleGroupProps,
145
+ grafana.AlertRuleGroup,
146
+ AlertRuleGroupAttributes,
147
+ | grafana.RouteGetAlertRuleGroupError
148
+ | grafana.RoutePutAlertRuleGroupError
149
+ | grafana.RouteDeleteAlertRuleGroupError
150
+ | GrafanaProvisionedObjectError
151
+ > = {
152
+ attributes: (live, props) => ({
153
+ folderUid: live.folderUid ?? props.folderUid,
154
+ group: props.group,
155
+ interval: live.interval ?? 0,
156
+ rules: (live.rules ?? []).map((rule) =>
157
+ normalizeRule(rule as unknown as Record<string, unknown>, props.folderUid, props.group),
158
+ ),
159
+ }),
160
+ create: (props) =>
161
+ grafana.routePutAlertRuleGroup({
162
+ FolderUID: props.folderUid,
163
+ Group: props.group,
164
+ ...body(props),
165
+ }),
166
+ destroy: (props, live) =>
167
+ Effect.gen(function* () {
168
+ const foreign = findForeignRule(live.rules);
169
+ if (foreign !== undefined) return yield* refuseForForeignRule(props, foreign);
170
+ yield* grafana.routeDeleteAlertRuleGroup({ FolderUID: props.folderUid, Group: props.group });
171
+ }),
172
+ fetchLive: (props) =>
173
+ grafana
174
+ .routeGetAlertRuleGroup({ FolderUID: props.folderUid, Group: props.group })
175
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
176
+ matches: (attributes, props) =>
177
+ attributes.interval === props.interval &&
178
+ declaredContentMatches(
179
+ props.rules.map((rule) =>
180
+ normalizeRule(rule as unknown as Record<string, unknown>, props.folderUid, props.group),
181
+ ),
182
+ attributes.rules,
183
+ ),
184
+ update: (props, live) =>
185
+ Effect.gen(function* () {
186
+ const foreign = findForeignRule(live.rules);
187
+ if (foreign !== undefined) return yield* refuseForForeignRule(props, foreign);
188
+ yield* warnOnDroppedRules(props.folderUid, props.group, declaredUids(props), live.rules);
189
+ yield* grafana.routePutAlertRuleGroup({
190
+ FolderUID: props.folderUid,
191
+ Group: props.group,
192
+ ...body(props),
193
+ });
194
+ }),
195
+ };
196
+
197
+ /**
198
+ * ⚠️ A LOCAL REIMPLEMENTATION OF `resource.ts`'s GENERIC `diff`, NOT A WRAP OF IT — deliberately.
199
+ * The generic `grafanaOperations(spec).diff` fetches live and maps it to `Attributes` entirely
200
+ * inside itself, with no seam to run the drop warning against the rules it read; wrapping it
201
+ * would mean a SECOND live fetch just to see what it saw. This resource's `diff` is the ONLY one
202
+ * in the family that needs a resource-specific side effect mid-comparison, so it re-reads
203
+ * `resource.ts`'s own logic here rather than changing the shared engine every other resource
204
+ * also uses. Keep this in sync with `resource.ts`'s `grafanaOperations.diff` if that ever
205
+ * changes shape.
206
+ */
207
+ const diff = ({
208
+ news,
209
+ output,
210
+ }: {
211
+ news: Input<AlertRuleGroupProps>;
212
+ output: AlertRuleGroupAttributes | undefined;
213
+ }) =>
214
+ Effect.gen(function* () {
215
+ if (output === undefined || !isResolved(news)) return undefined;
216
+ const live = yield* spec.fetchLive(news);
217
+ if (live === undefined) return { action: 'update' } as const;
218
+ yield* warnOnDroppedRules(news.folderUid, news.group, declaredUids(news), live.rules);
219
+ const mapped = spec.attributes(live, news);
220
+ return mapped !== undefined && spec.matches(mapped, news)
221
+ ? ({ action: 'noop' } as const)
222
+ : ({ action: 'update' } as const);
223
+ });
224
+
225
+ export const handlers = { ...grafanaHandlers(spec), diff };
226
+
227
+ export const GrafanaAlertRuleGroupProvider = () =>
228
+ Provider.effect(
229
+ GrafanaAlertRuleGroup,
230
+ Effect.succeed(GrafanaAlertRuleGroup.Provider.of(handlers)),
231
+ );
@@ -7,11 +7,25 @@
7
7
  * depend on.
8
8
  *
9
9
  * `Grafana.Datasource`, `Grafana.Folder`, `Grafana.Dashboard`, `Grafana.ContactPoint`,
10
- * `Grafana.MuteTiming` and `Grafana.MessageTemplate` ship here. `Grafana.AlertRuleGroup` and
11
- * `Grafana.NotificationPolicy` are stacked follow-up PRs on the same SDK operations — see
12
- * docs/grafana.md, docs/grafana-folder-dashboard.md and docs/grafana-alerting.md for what shipped
13
- * and what is still open.
10
+ * `Grafana.MuteTiming`, `Grafana.MessageTemplate`, `Grafana.AlertRuleGroup` and
11
+ * `Grafana.NotificationPolicy` all ship here — see docs/grafana.md, docs/grafana-folder-dashboard.md,
12
+ * docs/grafana-alerting.md, docs/grafana-alerting-rules.md and
13
+ * docs/grafana-notification-policy.md for what shipped.
14
14
  */
15
+ export {
16
+ type AlertRuleGroupAttributes,
17
+ type AlertRuleGroupProps,
18
+ type AlertRuleInput,
19
+ GrafanaAlertRuleGroup,
20
+ GrafanaAlertRuleGroupProvider,
21
+ } from './alert-rule-group.ts';
22
+ export {
23
+ type NotificationPolicyAttributes,
24
+ type NotificationPolicyProps,
25
+ type PolicyRoute,
26
+ GrafanaNotificationPolicy,
27
+ GrafanaNotificationPolicyProvider,
28
+ } from './notification-policy.ts';
15
29
  export {
16
30
  type DatasourceAttributes,
17
31
  type DatasourceProps,