@homeflare/alchemy 0.34.0 → 0.35.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 (48) 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 +5 -4
  10. package/dist/grafana/index.d.ts.map +1 -1
  11. package/dist/grafana/index.js +241 -139
  12. package/dist/grafana/index.js.map +11 -8
  13. package/dist/grafana/providers.d.ts +1 -1
  14. package/dist/grafana/providers.d.ts.map +1 -1
  15. package/dist/proxmox/index.js +349 -287
  16. package/dist/proxmox/index.js.map +8 -7
  17. package/dist/proxmox/network-apply-read.d.ts +12 -0
  18. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  19. package/dist/proxmox/network-apply.d.ts +2 -0
  20. package/dist/proxmox/network-apply.d.ts.map +1 -1
  21. package/dist/proxmox/node-network-form.d.ts +86 -54
  22. package/dist/proxmox/node-network-form.d.ts.map +1 -1
  23. package/dist/proxmox/node-network-wire.d.ts +100 -0
  24. package/dist/proxmox/node-network-wire.d.ts.map +1 -0
  25. package/dist/proxmox/node-network.d.ts +21 -106
  26. package/dist/proxmox/node-network.d.ts.map +1 -1
  27. package/docs/grafana-alerting-rules.md +143 -0
  28. package/docs/grafana-alerting.md +5 -3
  29. package/docs/grafana.md +4 -2
  30. package/docs/upstream-conformance.md +16 -4
  31. package/package.json +1 -1
  32. package/src/grafana/alert-rule-group-drop-warning.test.ts +107 -0
  33. package/src/grafana/alert-rule-group-drop-warning.ts +41 -0
  34. package/src/grafana/alert-rule-group-fixtures.ts +46 -0
  35. package/src/grafana/alert-rule-group-model.test.ts +75 -0
  36. package/src/grafana/alert-rule-group-model.ts +66 -0
  37. package/src/grafana/alert-rule-group-provenance.test.ts +116 -0
  38. package/src/grafana/alert-rule-group.test.ts +164 -0
  39. package/src/grafana/alert-rule-group.ts +231 -0
  40. package/src/grafana/index.ts +11 -4
  41. package/src/grafana/providers.ts +2 -0
  42. package/src/proxmox/constraints-host-forms.test.ts +2 -2
  43. package/src/proxmox/network-apply-read.ts +12 -0
  44. package/src/proxmox/network-apply.ts +2 -0
  45. package/src/proxmox/node-network-form.ts +93 -99
  46. package/src/proxmox/node-network-read-failure.test.ts +206 -0
  47. package/src/proxmox/node-network-wire.ts +239 -0
  48. package/src/proxmox/node-network.ts +155 -167
@@ -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,18 @@
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` and `Grafana.AlertRuleGroup` ship here.
11
+ * `Grafana.NotificationPolicy` — the singleton policy tree — is a stacked follow-up PR on the same
12
+ * SDK operations — see docs/grafana.md, docs/grafana-folder-dashboard.md and
13
+ * docs/grafana-alerting.md for what shipped and what is still open.
14
14
  */
15
+ export {
16
+ type AlertRuleGroupAttributes,
17
+ type AlertRuleGroupProps,
18
+ type AlertRuleInput,
19
+ GrafanaAlertRuleGroup,
20
+ GrafanaAlertRuleGroupProvider,
21
+ } from './alert-rule-group.ts';
15
22
  export {
16
23
  type DatasourceAttributes,
17
24
  type DatasourceProps,
@@ -12,6 +12,7 @@
12
12
  * deploys anything over HTTP already has one.
13
13
  */
14
14
  import * as Layer from 'effect/Layer';
15
+ import { GrafanaAlertRuleGroupProvider } from './alert-rule-group.ts';
15
16
  import { GrafanaContactPointProvider } from './contact-point.ts';
16
17
  import { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
17
18
  import { GrafanaDashboardProvider } from './dashboard.ts';
@@ -28,4 +29,5 @@ export const grafanaProviders = (target: GrafanaTarget) =>
28
29
  GrafanaContactPointProvider(),
29
30
  GrafanaMuteTimingProvider(),
30
31
  GrafanaMessageTemplateProvider(),
32
+ GrafanaAlertRuleGroupProvider(),
31
33
  ).pipe(Layer.provide(grafanaCredentials(target)));
@@ -11,7 +11,7 @@
11
11
  import { describe, expect, test } from 'bun:test';
12
12
  import { constraintsFor, formViolations } from './constraint-guard.ts';
13
13
  import type { PbsTarget, PveTarget } from './credentials.ts';
14
- import { createBody as ifaceCreateForm } from './node-network-form.ts';
14
+ import { createForm as ifaceCreateForm } from './node-network-form.ts';
15
15
  import { resolveGroups, targetForm } from './pbs-notification-target-form.ts';
16
16
  import { createForm as zfsCreateForm } from './zfs-pool-form.ts';
17
17
 
@@ -26,7 +26,7 @@ describe('the host families plan clean against their own creates', () => {
26
26
  * 🔴 THE BUG THIS PROOF FOUND. `createForm` was `body`, which never sent `iface` — and PVE marks
27
27
  * it REQUIRED on the POST while `{node}` is the only path parameter. Every NodeNetwork create
28
28
  * this repository could have made would have 400ed; nothing caught it because C1's three nodes
29
- * were all adopted, which takes the PUT path. See the 🔴 on `createBody`.
29
+ * were all adopted, which takes the PUT path. See the 🔴 on `createForm`.
30
30
  */
31
31
  test('an interface create carries iface, which PVE requires in the body', () => {
32
32
  const form = ifaceCreateForm({
@@ -18,6 +18,12 @@
18
18
  * drops the envelope, so the one readable pending signal is invisible to every other resource in
19
19
  * this package. Without the fetch below, `Proxmox.NetworkApply` could not diff honestly at all.
20
20
  * ★ `pveEnvelopeWith` in client.ts now carries the same member failover as every other call.
21
+ * ⛔ WHY THIS FAMILY DID NOT MOVE TO DISTILLED WITH `Proxmox.NodeNetwork` (2026-09-24, decision
22
+ * 43's walk-down): CONFIRMED against `@distilled.cloud/proxmox`'s own `protocol.ts` that its
23
+ * `transformResponse` unconditionally returns `body.data ?? {}` for every operation, before a
24
+ * generated call's typed output is even built — no typed call, current or future, could see
25
+ * `changes` without a protocol-level change upstream. `pveEnvelopeWith` stays because nothing
26
+ * else preserves the sibling this function needs.
21
27
  *
22
28
  * ⛔ AND THE INTERFACE ROWS CANNOT TELL YOU EITHER. MEASURED in PVE/INotify.pm `read_file`: when
23
29
  * `/etc/network/interfaces.new` exists, THAT is the file that gets parsed, so
@@ -188,6 +194,12 @@ const ATTEMPTS = 30;
188
194
  * of the task". Every mint returns a NEW token id, so a second mint is a different identity and
189
195
  * is not the owner — which is why this takes a `PveCredential` and the caller keeps one lease
190
196
  * across both calls, instead of widening the provisioning role to read its own tasks.
197
+ *
198
+ * ⛔ THE OTHER REASON THIS FAMILY DID NOT MOVE TO DISTILLED (2026-09-24): CHECKED against
199
+ * `@distilled.cloud/proxmox`'s own `Task.awaitTask` (`src/task.ts`) before deciding — it
200
+ * propagates a poll failure as a genuine typed `GetNodeTaskStatusError` rather than collapsing
201
+ * it to `UNREACHABLE` the way this function does, which would turn the ⛔ two paragraphs above
202
+ * into a failed `reconcile` on exactly the poll a mid-reload connection drop is expected to hit.
191
203
  */
192
204
  export const awaitTask = (
193
205
  target: PveTarget,
@@ -44,6 +44,8 @@
44
44
  * task it starts so it can poll it without `Sys.Audit`. ⛔ `Sys.Modify` ON A NODE IS NOT A SMALL
45
45
  * GRANT — it also carries that node's DNS, hosts file, time and service configuration. Granting
46
46
  * it cluster-wide to run this resource widens the provisioning role well beyond networking.
47
+ *
48
+ * ⛔ STAYS ON `client.ts` (2026-09-24) — network-apply-read.ts's header has the two MEASURED reasons.
47
49
  */
48
50
  import { Resource } from 'alchemy';
49
51
  import { isResolved } from 'alchemy/Diff';