@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,143 @@
1
+ # `Grafana.AlertRuleGroup` — `@homeflare/alchemy/grafana`
2
+
3
+ Second of three stacked PRs bringing Grafana alerting provisioning under Alchemy
4
+ ([grafana-alerting.md](./grafana-alerting.md) covers the first: `Grafana.ContactPoint`,
5
+ `Grafana.MuteTiming`, `Grafana.MessageTemplate`). Branched fresh off `main` after that PR merged,
6
+ so this is its own diff, not stacked on it. No live call was made writing this PR.
7
+
8
+ ## The unit: group, not rule
9
+
10
+ MEASURED against the SDK's generated operations: `PUT /v1/provisioning/folder/{FolderUID}/
11
+ rule-groups/{Group}` (`routePutAlertRuleGroup`) takes the evaluation `interval` AND the full
12
+ ordered `rules[]` TOGETHER, replacing the whole group in one call. The individual
13
+ `routePostAlertRule`/`routePutAlertRule` operations have no `interval` field at all and cannot
14
+ reorder rules within a group — declaring rules one at a time would leave a group's interval and
15
+ rule order either undeclarable or racy across N independent resources with no shared source of
16
+ truth. Grafana's own docs describe `X-Disable-Provenance` on the rule-group PUT as setting "the
17
+ provenance for the rule group and all its alert rules" — the group, not the rule, is Grafana's own
18
+ natural unit here. Mirrors Terraform's `grafana_rule_group` resource, which takes the identical
19
+ shape.
20
+
21
+ ## Example
22
+
23
+ ```ts
24
+ import { GrafanaAlertRuleGroup, grafanaProviders } from '@homeflare/alchemy/grafana';
25
+
26
+ export class InfraAlerts extends GrafanaAlertRuleGroup('infra-alerts', {
27
+ folderUid: 'infra',
28
+ group: 'default',
29
+ interval: 60,
30
+ rules: [
31
+ {
32
+ uid: 'high-cpu',
33
+ title: 'High CPU',
34
+ condition: 'A',
35
+ data: [/* … AlertQuery, the SDK's own shape … */],
36
+ for: '5m',
37
+ noDataState: 'NoData',
38
+ execErrState: 'Alerting',
39
+ },
40
+ ],
41
+ }) {}
42
+ ```
43
+
44
+ ## `uid` is required on every declared rule
45
+
46
+ Same doctrine as every other resource in this family. `ProvisionedAlertRuleInput.uid` is optional
47
+ on the wire (Grafana assigns one if omitted), but locating a specific rule this resource didn't
48
+ just create would then mean matching by title — the ambiguity `resource.ts`'s header and netbox's
49
+ `soleMatch` exist to catch everywhere else in this family.
50
+
51
+ ## Rule order is significant, never reordered
52
+
53
+ `subset-match.ts`'s array comparison is already position-significant (exact length, then
54
+ element-wise) — this resource relies on that as-is. Declaring rules in a different order than what
55
+ is live is a genuine content change (Grafana evaluates and displays a group's rules in array
56
+ order), proven by a dedicated test (`alert-rule-group.test.ts`) that declares the same two rules
57
+ reversed and asserts `matches` is `false`.
58
+
59
+ ## Dropped-rule warning — accepted, never silent
60
+
61
+ ⚠️ **Flagged by an adversarial review of this PR.** A whole-group `PUT` replaces the entire rule
62
+ list — a live rule with no counterpart in the declaration (a human adding one in the Grafana UI
63
+ minutes before a deploy, ordinary writable provenance, nothing for the refusal above to catch) is
64
+ removed with no error, and `diff` has no distinct action to report a lossy update under
65
+ (`alchemy/Diff`'s `Diff` type is exactly `NoopDiff | UpdateDiff | ReplaceDiff`). This is accepted
66
+ by design — the group is still the unit — but never silent:
67
+ `alert-rule-group-drop-warning.ts`'s `warnOnDroppedRules` logs a loud `Effect.logWarning` naming
68
+ every dropped rule by title and uid, called from BOTH this resource's custom `diff` (so
69
+ `bun run plan` shows it before anything is written) and from `update` itself (so it is visible
70
+ wherever `reconcile` runs directly too). Mirrors the house pattern
71
+ `proxmox/unreadable-read.ts`'s `unreadableWarning` established.
72
+
73
+ `diff` is a LOCAL reimplementation of `resource.ts`'s generic `grafanaOperations(spec).diff`, not a
74
+ wrap of it — the generic version has no seam to run a side effect against the live rules it reads,
75
+ and wrapping it would mean fetching live twice just to see what it already saw. This is the only
76
+ resource in the family that needs this; every other resource still uses the shared engine
77
+ unmodified.
78
+
79
+ ## Volatile-field normalization (`alert-rule-group-model.ts`)
80
+
81
+ `normalizeRule` strips three fields before ANY comparison, each measured against
82
+ `ProvisionedAlertRule`/`ProvisionedAlertRuleInput`:
83
+
84
+ - `id` — the internal numeric row id. Exists only on the READ shape, absent from the WRITE shape —
85
+ confirms it is Grafana-managed, never declarable.
86
+ - `updated` — a server-set timestamp. Same evidence: read-only shape only.
87
+ - `provenance` — read-only too, but handled by the foreign-provenance refusal below rather than
88
+ compared for drift: an object this family just wrote reads back `provenance: "api"`, which the
89
+ declaration never sets, so comparing it would show a false `update` on every rule this family
90
+ owns.
91
+
92
+ `folderUID`/`ruleGroup`/`orgID` are forced to the group's own canonical values (mirrors
93
+ `dashboard-model.ts`'s `normalizeModel` forcing `uid`) — a rule's declaration never repeats its own
94
+ group/folder. ⚠️ `orgID` defaults to `1`, not measured against a multi-org instance (none exists in
95
+ this house yet); a rule's own spec functions have no access to `GrafanaTarget.orgId` the way
96
+ credentials do.
97
+
98
+ ## Foreign-provenance refusal — per rule, whole-group refuse
99
+
100
+ Unlike `ContactPoint`/`MessageTemplate`, the `AlertRuleGroup` type itself (what
101
+ `routeGetAlertRuleGroup` returns) carries NO top-level `provenance` field — measured:
102
+ `folderUid?`/`interval?`/`rules?`/`title?` only. Each RULE inside `rules[]` has its own
103
+ (`ProvisionedAlertRule.provenance`), and Grafana's docs say a new rule's provenance "must match the
104
+ provenance value configured for its rule group" — so this resource checks every live rule via
105
+ `alerting-provenance.ts`'s shared `isForeignProvenance`/`refuseIfForeignProvenance` and refuses the
106
+ WHOLE group's write if ANY rule carries a foreign provenance, naming the first one found. An empty
107
+ live group (no rules yet) has nothing to check and is never foreign. The same "missing means
108
+ foreign, never `ProvenanceNone`" fail-closed rule kit PR 250's adversarial review established
109
+ applies here unchanged — pinned by `alert-rule-group-provenance.test.ts`.
110
+
111
+ ## A declared group whose folder does not exist — refuses, never auto-creates
112
+
113
+ This resource makes no "does the folder exist" call of its own and does not depend on or invoke
114
+ `Grafana.Folder`'s create path. Measured: `RoutePutAlertRuleGroupError` is
115
+ `BadRequest | Forbidden | GrafanaOpError` — no `NotFound` case at all — so a nonexistent
116
+ `folderUid` fails the PUT as a typed `BadRequest`, left uncaught and propagated, which fails the
117
+ whole `reconcile` and stops the deploy. Declare a `Grafana.Folder` for it first; the two resources
118
+ sharing one `folderUid` string is the dependency. Auto-creating a folder from inside this resource
119
+ would blur ownership the same way `provisioned.ts`'s "one owner per resource" principle already
120
+ guards against elsewhere in this family. ⚠️ Not re-measured against a live instance — inferred from
121
+ the declared error union, the same evidentiary bar `folder.ts`'s own `version` note uses.
122
+
123
+ ## Delete defaults to `retain`
124
+
125
+ Deleting a group deletes every rule in it in one call (`routeDeleteAlertRuleGroup`) — the same
126
+ multi-object-cascade class `Grafana.Folder`/`openbao/mount.ts` already guard with
127
+ `defaultRemovalPolicy: 'retain'`. A stack that wants the cascade opts in with
128
+ `.pipe(RemovalPolicy.destroy())`.
129
+
130
+ ## Tests
131
+
132
+ `alert-rule-group.test.ts` (fetchLive/create/reconcile/order/update/destroy),
133
+ `alert-rule-group-model.test.ts` (pure `normalizeRule`/`toWireRule`),
134
+ `alert-rule-group-provenance.test.ts` (foreign, missing, explicit-none, empty-group) and
135
+ `alert-rule-group-drop-warning.test.ts` — split four ways to stay under the house's 250-line file
136
+ cap, sharing fixtures from `alert-rule-group-fixtures.ts`. Prove: a GET never carries a body; a
137
+ nonexistent folder propagates `BadRequest` rather than being folded to absent or silently creating
138
+ one; a foreign-provenance rule (explicit `"file"` or MISSING) refuses the whole group's
139
+ update/destroy while an explicit `""` is writable; reordering the same rules shows `update`; an
140
+ unchanged declaration is a noop despite `id`/`updated`/`provenance` differing on every rule; and —
141
+ `alert-rule-group-drop-warning.test.ts`, added after an adversarial review — a live rule the
142
+ declaration doesn't mention is named by title and uid in a logged warning from both `diff` and
143
+ `update`, and nothing is warned when every live rule is declared.
@@ -3,9 +3,11 @@
3
3
  Declares Grafana's alerting-provisioning objects (decision 40: every internal alert lands in one
4
4
  place). Ships in three stacked PRs, in the order this file's own history records:
5
5
 
6
- 1. **This PR:** `Grafana.ContactPoint`, `Grafana.MuteTiming`, `Grafana.MessageTemplate`.
7
- 2. Next: `Grafana.AlertRuleGroup`.
8
- 3. Then: `Grafana.NotificationPolicy` — the singleton policy tree, last on purpose (it references
6
+ 1. **`Grafana.ContactPoint`, `Grafana.MuteTiming`, `Grafana.MessageTemplate`** — this file, kit
7
+ PR 250.
8
+ 2. **`Grafana.AlertRuleGroup`** — [grafana-alerting-rules.md](./grafana-alerting-rules.md), a
9
+ separate stacked PR (its own diff off `main`, not this one) once this file's doc budget filled.
10
+ 3. Next: `Grafana.NotificationPolicy` — the singleton policy tree, last on purpose (it references
9
11
  contact points, mute timings and templates by name).
10
12
 
11
13
  All three ship on the same `@distilled.cloud/grafana@0.2.0` operations
package/docs/grafana.md CHANGED
@@ -128,7 +128,9 @@ export class TeslaMateDatasource extends GrafanaDatasource('teslamate-datasource
128
128
  [grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
129
129
  - **`Grafana.ContactPoint`, `Grafana.MuteTiming` and `Grafana.MessageTemplate` ship** — see
130
130
  [grafana-alerting.md](./grafana-alerting.md), not repeated here.
131
- - **`Grafana.AlertRuleGroup` and `Grafana.NotificationPolicy`** — stacked follow-up PRs on the same
132
- operations, per grafana-alerting.md's own ordering; not blocked on anything.
131
+ - **`Grafana.AlertRuleGroup` ships** — see [grafana-alerting-rules.md](./grafana-alerting-rules.md),
132
+ not repeated here.
133
+ - **`Grafana.NotificationPolicy`** — the last stacked follow-up PR, the singleton policy tree, on
134
+ the same operations; not blocked on anything.
133
135
  - **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
134
136
  still carry (see upstream-conformance.md); a maintainer decision on the ownership-check shape.
@@ -267,8 +267,9 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
267
267
  normalization that makes an unchanged dashboard a true noop are all in
268
268
  [grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
269
269
 
270
- ✅ **`Grafana.ContactPoint`/`Grafana.MuteTiming`/`Grafana.MessageTemplate` shipped** (this PR,
271
- first of three stacked PRs — `Grafana.AlertRuleGroup` then `Grafana.NotificationPolicy` follow):
270
+ ✅ **`Grafana.ContactPoint`/`Grafana.MuteTiming`/`Grafana.MessageTemplate` shipped** (kit PR
271
+ 250, first of three stacked PRs — `Grafana.AlertRuleGroup` then `Grafana.NotificationPolicy`
272
+ follow):
272
273
  the foreign-provenance refusal (`alerting-provenance.ts`, an allowlist of Grafana's own
273
274
  `ProvenanceNone`/`ProvenanceAPI` values, measured against `pkg/services/ngalert/models` and
274
275
  Grafana's v13.1 docs) and the secret-settings-ref seam (`secret-refs.ts`, shared with
@@ -288,8 +289,19 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
288
289
  timings or templates configured either (consistent with the alerting-provisioning gap having
289
290
  been real, not merely unexploited).
290
291
 
291
- **Still open, not this PR's scope:** `Grafana.AlertRuleGroup`/`Grafana.NotificationPolicy` —
292
- the next two stacked PRs, no longer blocked on the SDK.
292
+ ✅ **`Grafana.AlertRuleGroup` shipped** (this PR, second of three, branched fresh off `main`
293
+ after PR 250 merged): group, not per-rule, is the unit — `PUT .../rule-groups/{Group}` owns
294
+ evaluation `interval` and the full ordered `rules[]` together, which per-rule operations
295
+ cannot express (no `interval` field, no reordering). Rule order is significant, proven by a
296
+ dedicated test. The per-rule provenance refusal (each `ProvisionedAlertRule.provenance`, since
297
+ the group type itself carries none) reuses `alerting-provenance.ts` unchanged, including the
298
+ missing-means-foreign fix from PR 250's review. A declared group whose folder does not exist
299
+ refuses as a typed `BadRequest` (`RoutePutAlertRuleGroupError` has no `NotFound` case) rather
300
+ than being auto-created — this resource never calls `Grafana.Folder`'s create path. Full
301
+ detail: [grafana-alerting-rules.md](./grafana-alerting-rules.md).
302
+
303
+ **Still open, not this PR's scope:** `Grafana.NotificationPolicy` — the last stacked PR, the
304
+ singleton policy tree, no longer blocked on the SDK.
293
305
  - ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
294
306
  (H1), and credentials come from an explicit env var NAME at call time rather than an
295
307
  `alchemy/Auth` provider (S24) — here the house's own `grafanaCredentials(target)`, not the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `warnOnDroppedRules` — pure logic, then wired through `diff`/`update` on a fake Grafana. Captures
3
+ * `Effect.logWarning` with a `Logger`, the house pattern `caddy/config-format-warning.test.ts`
4
+ * established for asserting on a warning without turning it into a refusal.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Logger from 'effect/Logger';
9
+ import { warnOnDroppedRules } from './alert-rule-group-drop-warning.ts';
10
+ import { PATH, liveJson, liveRule, props } from './alert-rule-group-fixtures.ts';
11
+ import { handlers, spec } from './alert-rule-group.ts';
12
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
13
+ import { grafanaOperations } from './resource.ts';
14
+
15
+ /** Runs `effect` and returns its result plus every `Effect.logWarning` line it emitted. */
16
+ const withWarnings = async <A>(effect: Effect.Effect<A, unknown, never>) => {
17
+ const warnings: string[] = [];
18
+ const collect = Logger.make((options) => {
19
+ if (options.logLevel === 'Warn') warnings.push(String(options.message));
20
+ });
21
+ const value = await Effect.runPromise(effect.pipe(Effect.provide(Logger.layer([collect]))));
22
+ return { value, warnings };
23
+ };
24
+
25
+ describe('warnOnDroppedRules — pure', () => {
26
+ test('logs nothing when every live rule is declared', async () => {
27
+ const { warnings } = await withWarnings(
28
+ warnOnDroppedRules('infra', 'default', new Set(['high-cpu']), [
29
+ { title: 'High CPU', uid: 'high-cpu' },
30
+ ]) as Effect.Effect<void, unknown, never>,
31
+ );
32
+ expect(warnings).toEqual([]);
33
+ });
34
+
35
+ test('names a dropped rule by title and uid', async () => {
36
+ const { warnings } = await withWarnings(
37
+ warnOnDroppedRules('infra', 'default', new Set(['high-cpu']), [
38
+ { title: 'High CPU', uid: 'high-cpu' },
39
+ { title: 'Low Disk', uid: 'low-disk' },
40
+ ]) as Effect.Effect<void, unknown, never>,
41
+ );
42
+ expect(warnings).toHaveLength(1);
43
+ expect(warnings[0]).toContain('infra/default');
44
+ expect(warnings[0]).toContain('REMOVES 1 rule');
45
+ expect(warnings[0]).toContain('Low Disk (low-disk)');
46
+ expect(warnings[0]).not.toContain('High CPU');
47
+ });
48
+
49
+ test('a rule with no uid at all is never named — cannot express "keep it" anyway', async () => {
50
+ const { warnings } = await withWarnings(
51
+ warnOnDroppedRules('infra', 'default', new Set(), [{ title: 'Mystery' }]) as Effect.Effect<
52
+ void,
53
+ unknown,
54
+ never
55
+ >,
56
+ );
57
+ expect(warnings).toEqual([]);
58
+ });
59
+ });
60
+
61
+ describe('wired into diff and update', () => {
62
+ test('diff logs the dropped-rule warning before anything is written', async () => {
63
+ const extra = { ...liveRule, title: 'Low Disk', uid: 'low-disk' };
64
+ const withExtra = { ...liveJson, rules: [liveRule, extra] };
65
+ const fake = fakeGrafana((method, url) =>
66
+ method === 'GET' && url.pathname === PATH
67
+ ? Response.json(withExtra)
68
+ : fakeFailure(500, 'diff should not write'),
69
+ );
70
+ const { warnings, value } = await withWarnings(
71
+ handlers
72
+ .diff({
73
+ news: props,
74
+ output: { folderUid: 'infra', group: 'default', interval: 60, rules: [] },
75
+ })
76
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))) as Effect.Effect<
77
+ unknown,
78
+ unknown,
79
+ never
80
+ >,
81
+ );
82
+ expect(value).toEqual({ action: 'update' });
83
+ expect(warnings).toHaveLength(1);
84
+ expect(warnings[0]).toContain('Low Disk (low-disk)');
85
+ });
86
+
87
+ test('update logs the same warning right before the PUT that would drop the rule', async () => {
88
+ const extra = { ...liveRule, title: 'Low Disk', uid: 'low-disk' };
89
+ const withExtra = { ...liveJson, rules: [liveRule, extra] };
90
+ const fake = fakeGrafana((method, url) => {
91
+ if (method === 'GET' && url.pathname === PATH) return Response.json(withExtra);
92
+ if (method === 'PUT' && url.pathname === PATH) return Response.json(withExtra);
93
+ return fakeFailure(500, 'unexpected request');
94
+ });
95
+ const { warnings } = await withWarnings(
96
+ grafanaOperations(spec)
97
+ .reconcile({ ...props, interval: 120 })
98
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))) as Effect.Effect<
99
+ unknown,
100
+ unknown,
101
+ never
102
+ >,
103
+ );
104
+ expect(warnings).toHaveLength(1);
105
+ expect(warnings[0]).toContain('Low Disk (low-disk)');
106
+ });
107
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The warning `Grafana.AlertRuleGroup` logs when a whole-group PUT would silently drop a live rule
3
+ * the declaration never mentions — flagged by an adversarial review of this PR: a human adding a
4
+ * rule in the Grafana UI minutes before a deploy (ordinary, writable provenance — nothing foreign
5
+ * to refuse on) would otherwise be removed with no visible reason, `diff` only ever saying
6
+ * "update". `Grafana.AlertRuleGroup` is still the group as the unit BY DESIGN (that file's own
7
+ * header) — this does not refuse the write, only makes the loss visible.
8
+ *
9
+ * ★ `Effect.logWarning`, NEVER A REFUSAL — mirrors the house pattern `proxmox/unreadable-read.ts`'s
10
+ * `unreadableWarning` established. Alchemy's `Diff` type is exactly `NoopDiff | UpdateDiff |
11
+ * ReplaceDiff` (no fourth action to carry "update, but lossy" through to `bun run plan`'s printed
12
+ * action), and refusing here would contradict "the group is the unit": a whole-group replacement
13
+ * dropping an out-of-band rule is EXPECTED behavior for this resource, not an error condition —
14
+ * only a silent one worth surfacing loudly.
15
+ *
16
+ * ★ NO IMPORT OF `AlertRuleGroupProps` — deliberately primitive arguments (`folderUid`/`group`/a
17
+ * plain `Set` of declared uids) rather than the whole props object, so this file has no
18
+ * dependency back on `alert-rule-group.ts` and can be unit-tested with plain data.
19
+ */
20
+ import * as Effect from 'effect/Effect';
21
+
22
+ export const warnOnDroppedRules = (
23
+ folderUid: string,
24
+ group: string,
25
+ declaredUids: ReadonlySet<string>,
26
+ liveRules: ReadonlyArray<{ readonly uid?: unknown; readonly title?: unknown }> | undefined,
27
+ ): Effect.Effect<void> =>
28
+ Effect.gen(function* () {
29
+ const dropped = (liveRules ?? [])
30
+ .filter((rule): rule is { title?: unknown; uid: string } => typeof rule.uid === 'string')
31
+ .filter((rule) => !declaredUids.has(rule.uid));
32
+ if (dropped.length === 0) return;
33
+ const names = dropped
34
+ .map((rule) => `${typeof rule.title === 'string' ? rule.title : '(untitled)'} (${rule.uid})`)
35
+ .join(', ');
36
+ yield* Effect.logWarning(
37
+ `Grafana.AlertRuleGroup ${folderUid}/${group}: this update REMOVES ${dropped.length} ` +
38
+ `rule(s) not in the declaration: ${names}. Whole-group replacement is by design — add ` +
39
+ `them to the declaration to keep them, or give them their own group.`,
40
+ );
41
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared fixtures for `alert-rule-group.test.ts`/`alert-rule-group-provenance.test.ts` — split to
3
+ * keep each file under the house's 250-line cap. Deliberately NOT named `*.test.ts` — see
4
+ * `contact-point-fixtures.ts`'s own header for why.
5
+ */
6
+ export const PATH = '/api/v1/provisioning/folder/infra/rule-groups/default';
7
+
8
+ export const liveRule = {
9
+ condition: 'A',
10
+ data: [{ refId: 'A' }],
11
+ execErrState: 'Alerting',
12
+ folderUID: 'infra',
13
+ for: '5m',
14
+ id: 7,
15
+ noDataState: 'NoData',
16
+ orgID: 1,
17
+ provenance: 'api',
18
+ ruleGroup: 'default',
19
+ title: 'High CPU',
20
+ uid: 'high-cpu',
21
+ updated: '2026-09-24T00:00:00Z',
22
+ };
23
+
24
+ export const liveJson = {
25
+ folderUid: 'infra',
26
+ interval: 60,
27
+ rules: [liveRule],
28
+ title: 'default',
29
+ };
30
+
31
+ export const ruleInput = {
32
+ condition: 'A',
33
+ data: [{ refId: 'A' }],
34
+ execErrState: 'Alerting',
35
+ for: '5m',
36
+ noDataState: 'NoData',
37
+ title: 'High CPU',
38
+ uid: 'high-cpu',
39
+ };
40
+
41
+ export const props = {
42
+ folderUid: 'infra',
43
+ group: 'default',
44
+ interval: 60,
45
+ rules: [ruleInput],
46
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `normalizeRule`/`toWireRule` in isolation — no SDK, no fake, no Effect. Mirrors
3
+ * `dashboard-model.test.ts`. Proves the volatile fields (`id`, `updated`, `provenance`) are
4
+ * stripped and `folderUID`/`ruleGroup`/`orgID` are forced to canonical values regardless of what a
5
+ * live-read rule embeds, which is what makes a pasted export and a freshly-read live rule compare
6
+ * equal in `alert-rule-group.test.ts`.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { normalizeRule, toWireRule } from './alert-rule-group-model.ts';
10
+
11
+ describe('normalizeRule', () => {
12
+ test('strips id, updated and provenance', () => {
13
+ const rule = {
14
+ condition: 'A',
15
+ id: 42,
16
+ provenance: 'api',
17
+ title: 'High CPU',
18
+ updated: '2026-09-24T00:00:00Z',
19
+ };
20
+ expect(normalizeRule(rule, 'infra', 'default')).toEqual({
21
+ condition: 'A',
22
+ title: 'High CPU',
23
+ folderUID: 'infra',
24
+ ruleGroup: 'default',
25
+ orgID: 1,
26
+ });
27
+ });
28
+
29
+ test('forces folderUID/ruleGroup/orgID to the canonical values, overwriting stale ones', () => {
30
+ const rule = { folderUID: 'stale', orgID: 99, ruleGroup: 'stale-group', title: 'High CPU' };
31
+ expect(normalizeRule(rule, 'infra', 'default')).toEqual({
32
+ title: 'High CPU',
33
+ folderUID: 'infra',
34
+ ruleGroup: 'default',
35
+ orgID: 1,
36
+ });
37
+ });
38
+
39
+ test('a declared rule and a live rule that only disagree on volatile fields normalize equal', () => {
40
+ const declared = { condition: 'A', title: 'High CPU' };
41
+ const live = {
42
+ condition: 'A',
43
+ folderUID: 'infra',
44
+ id: 7,
45
+ orgID: 1,
46
+ provenance: 'api',
47
+ ruleGroup: 'default',
48
+ title: 'High CPU',
49
+ updated: '2026-09-24T00:00:00Z',
50
+ };
51
+ expect(normalizeRule(declared, 'infra', 'default')).toEqual(
52
+ normalizeRule(live, 'infra', 'default'),
53
+ );
54
+ });
55
+
56
+ test('leaves a real content change genuinely different', () => {
57
+ const declared = { title: 'High CPU v2' };
58
+ const live = { title: 'High CPU' };
59
+ expect(normalizeRule(declared, 'infra', 'default')).not.toEqual(
60
+ normalizeRule(live, 'infra', 'default'),
61
+ );
62
+ });
63
+ });
64
+
65
+ describe('toWireRule', () => {
66
+ test('fills in the group-derived fields, orgID defaulted to 1', () => {
67
+ const rule = { condition: 'A', title: 'High CPU', uid: 'high-cpu' } as never;
68
+ expect(toWireRule(rule, 'infra', 'default')).toMatchObject({
69
+ folderUID: 'infra',
70
+ orgID: 1,
71
+ ruleGroup: 'default',
72
+ uid: 'high-cpu',
73
+ });
74
+ });
75
+ });
@@ -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
+ });