@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,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,10 +3,13 @@
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
9
- contact points, mute timings and templates by name).
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. **`Grafana.NotificationPolicy`** — [grafana-notification-policy.md](./grafana-notification-policy.md),
11
+ the singleton policy tree, last on purpose (it references contact points, mute timings and
12
+ templates by name).
10
13
 
11
14
  All three ship on the same `@distilled.cloud/grafana@0.2.0` operations
12
15
  [grafana.md](./grafana.md#the-sdk-gap--history-and-what-shipped-on-top-of-the-fix) already
@@ -0,0 +1,127 @@
1
+ # `Grafana.NotificationPolicy` — `@homeflare/alchemy/grafana`
2
+
3
+ Third and last of the stacked PRs bringing Grafana alerting provisioning under Alchemy
4
+ ([grafana-alerting.md](./grafana-alerting.md): `Grafana.ContactPoint`/`Grafana.MuteTiming`/
5
+ `Grafana.MessageTemplate`; [grafana-alerting-rules.md](./grafana-alerting-rules.md):
6
+ `Grafana.AlertRuleGroup`). Branched fresh off `main` after that PR merged. No live call was made
7
+ writing this PR.
8
+
9
+ ## A singleton — adopt and update only
10
+
11
+ `GET`/`PUT /v1/provisioning/policies` always address the one tree an instance has; there is no path
12
+ parameter. MEASURED: `RouteGetPolicyTreeError` is `Forbidden | GrafanaOpError` — no `NotFound` case
13
+ at all, so `fetchLive` never returns `undefined`. `reconcile`'s `before === undefined` branch
14
+ (`resource.ts`) is therefore dead code for this resource: there is no create in the sense every
15
+ other resource in this family has, and `spec.create` exists only because `GrafanaSpec.create` is a
16
+ required field on the shared type — implemented as the same `PUT` `update` sends, defensively,
17
+ never actually reached.
18
+
19
+ ## Two gates before `routeResetPolicyTree` ever runs
20
+
21
+ `destroy` never calls `routeResetPolicyTree` unless `allowReset: true` is explicitly declared — a
22
+ gate independent of Alchemy's own removal policy:
23
+
24
+ 1. **`defaultRemovalPolicy: 'retain'`** — the same convention every multi-object-cascade resource
25
+ in this family uses (`Grafana.Folder`, `Grafana.AlertRuleGroup`, every `Bao.*`). Alchemy's own
26
+ engine never calls this resource's `delete` handler at all unless a stack opts in with
27
+ `.pipe(RemovalPolicy.destroy())`.
28
+ 2. **`allowReset` (a prop, not a stack-level policy)** — even a stack that HAS opted into destroy
29
+ at the Alchemy level still needs this resource's own explicit `allowReset: true` before
30
+ `destroy` will actually reset. Without it, `destroy` logs a warning and returns — retained.
31
+
32
+ Two gates, not one, because `routeResetPolicyTree` is uniquely destructive among everything this
33
+ family calls: it wipes every declared route, receiver reference and matcher back to Grafana's own
34
+ bare default, for the WHOLE instance, not one object. A test
35
+ (`notification-policy-refusals.test.ts`) proves `destroy` sends no `DELETE` without `allowReset`,
36
+ and a foreign-provenance tree still refuses `destroy` even WITH `allowReset: true`.
37
+
38
+ ## The tree is opaque `Record<string, unknown>`
39
+
40
+ Same reasoning as `mute-timing.ts`'s `timeIntervals`: Grafana's `Matchers`/`ObjectMatchers`/
41
+ `MatchRegexps` (matcher expression shapes) are complex nested union/array types this family does
42
+ not attempt to model field-by-field. This family's own measured passthrough behavior
43
+ (`mute-timing.ts`'s file header) means an opaque pass-through round-trips correctly regardless.
44
+
45
+ ## Route order is significant
46
+
47
+ Same as `Grafana.AlertRuleGroup`'s rules: `subset-match.ts`'s array comparison is
48
+ position-significant, and Alertmanager itself routes an alert to the FIRST matching route in array
49
+ order — reordering routes is a genuine routing change, proven by a test that declares the same two
50
+ routes reversed and asserts a mismatch.
51
+
52
+ ## Foreign-provenance refusal
53
+
54
+ Unchanged from kit PR 250/254, including the missing-means-foreign fix: `Route.provenance` is
55
+ optional on the wire, and `undefined` is never folded into `""`. `create` is never at risk — it is
56
+ unreachable (above).
57
+
58
+ ## Grafana-injected defaults — tolerated the `declaredContentMatches` way
59
+
60
+ `matches()` runs the (`provenance`-stripped) declared and live trees through `subset-match.ts`'s
61
+ tolerant-subset comparison — the same mechanism `Grafana.Dashboard`'s panel-`datasource`
62
+ auto-decoration and `Grafana.ContactPoint`'s settings defaults already rely on: a live tree may
63
+ carry additional fields, at any depth, the declaration never mentioned (a default
64
+ `group_wait`/`group_interval`/`repeat_interval`/`group_by: []` Grafana injects when the declaration
65
+ omits them is the plausible case) without failing the match. ⚠️ Not measured against a live
66
+ instance — no live policy tree exists in this house to read — the documented, conservative default
67
+ every unmeasured value shape in this family takes.
68
+
69
+ ## Dropped-route warning — accepted, never silent
70
+
71
+ Same fix kit PR 254 made for `Grafana.AlertRuleGroup`, applied here: a whole-tree `PUT` REPLACES
72
+ every route, so a nested route added out-of-band is removed with no error and no distinct `Diff`
73
+ action to report it under. `notification-policy-drop-warning.ts`'s `warnOnDroppedRoutes` logs every
74
+ dropped route by receiver and dotted-index path, called from both a local reimplementation of
75
+ `diff` and from `update` itself — mirrors `alert-rule-group-drop-warning.ts` exactly, including
76
+ being a LOCAL reimplementation of `resource.ts`'s generic `diff` rather than a wrap of it (no seam
77
+ in the generic version for this side effect without fetching live twice).
78
+
79
+ ⚠️ **Identity is structural, not a stable key** — Grafana's policy routes have no id/uid.
80
+ `FlatRoute.key` (`notification-policy-model.ts`) is the chain of ancestor own-fields from the root
81
+ down to a node, `\0`-joined: two nodes are "the same" only under the identical lineage of parents,
82
+ which makes an identical route under a DIFFERENT parent correctly distinct while staying
83
+ order-insensitive among siblings (a shared parent contributes the same key to every child
84
+ regardless of index) — reordering the same routes logs nothing, only genuine removal does.
85
+
86
+ ⛔ **Fixed after an adversarial review of this PR: the first version's identity collided across the
87
+ WHOLE TREE, not just among siblings.** A plain `Set` of every node's own-fields JSON meant a
88
+ duplicate route added anywhere (e.g. a second `severity=warning -> slack` alongside an
89
+ already-declared one) matched the existing `Set` entry and went unwarned, even though the `PUT`
90
+ still drops it — worse than the "siblings only" limitation the original comment claimed. Every key
91
+ is now counted as a MULTISET (declared count vs. live count) rather than checked for membership: a
92
+ key is "dropped" only for the amount its live count exceeds its declared count, so N identical live
93
+ occurrences under the same parent are dropped only past however many the declaration itself
94
+ repeats — proven by four tests: a duplicate added live warns; a plain sibling reorder doesn't; an
95
+ identical route under a different, undeclared parent still warns (distinct lineage); and two
96
+ declared duplicates against two live duplicates warns nothing (counts match).
97
+
98
+ ## Example
99
+
100
+ ```ts
101
+ import { GrafanaNotificationPolicy, grafanaProviders } from '@homeflare/alchemy/grafana';
102
+
103
+ export class Policy extends GrafanaNotificationPolicy('estate-policy', {
104
+ route: {
105
+ receiver: 'default-receiver',
106
+ group_by: ['alertname'],
107
+ routes: [
108
+ { receiver: 'pagerduty', matchers: ['severity=critical'] },
109
+ { receiver: 'slack', matchers: ['severity=warning'] },
110
+ ],
111
+ },
112
+ }) {}
113
+ ```
114
+
115
+ ## Tests
116
+
117
+ `notification-policy.test.ts` (fetchLive/reconcile/order), `notification-policy-model.test.ts`
118
+ (pure `normalizeRoute`/`flattenRoutes`), `notification-policy-refusals.test.ts` (provenance +
119
+ reset-gate) and `notification-policy-drop-warning.test.ts` — split four ways to stay under the
120
+ house's 250-line file cap, sharing fixtures from `notification-policy-fixtures.ts`. Prove: a GET
121
+ never carries a body; reconcile never sends a create-shaped write; a foreign-provenance tree
122
+ (explicit `"file"` or MISSING) refuses update while an explicit `""` is writable; `destroy` never
123
+ resets without `allowReset: true`, and still refuses a foreign tree even with it; reordering routes
124
+ shows `update`; a live route the declaration doesn't mention is named in a logged warning from both
125
+ `diff` and `update`, while a plain reorder logs nothing; and — the multiset fix — a duplicate route
126
+ added live warns while an identical declared/live duplicate pair doesn't, and an identical route
127
+ under a different, undeclared parent is still correctly reported as dropped.
package/docs/grafana.md CHANGED
@@ -122,13 +122,19 @@ export class TeslaMateDatasource extends GrafanaDatasource('teslamate-datasource
122
122
  // …then provide `grafanaProviders({ baseUrl, tokenEnv })` alongside the stack's other providers.
123
123
  ```
124
124
 
125
+ ## Family status
126
+
127
+ Every alerting-provisioning object this family set out to cover now ships:
128
+
129
+ - **`Grafana.Folder` and `Grafana.Dashboard`** — see
130
+ [grafana-folder-dashboard.md](./grafana-folder-dashboard.md).
131
+ - **`Grafana.ContactPoint`, `Grafana.MuteTiming` and `Grafana.MessageTemplate`** — see
132
+ [grafana-alerting.md](./grafana-alerting.md).
133
+ - **`Grafana.AlertRuleGroup`** — see [grafana-alerting-rules.md](./grafana-alerting-rules.md).
134
+ - **`Grafana.NotificationPolicy`** — the singleton policy tree, last on purpose (it references the
135
+ others by name) — see [grafana-notification-policy.md](./grafana-notification-policy.md).
136
+
125
137
  ## Not covered
126
138
 
127
- - **`Grafana.Folder` and `Grafana.Dashboard` ship** — see
128
- [grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
129
- - **`Grafana.ContactPoint`, `Grafana.MuteTiming` and `Grafana.MessageTemplate` ship** — see
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.
133
139
  - **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
134
140
  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,33 @@ 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** (kit PR 254, 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. A whole-group
301
+ write silently dropping a live rule not in the declaration (found by that PR's own adversarial
302
+ review) is now warned about loudly rather than silent, never a refusal. Full detail:
303
+ [grafana-alerting-rules.md](./grafana-alerting-rules.md).
304
+
305
+ ✅ **`Grafana.NotificationPolicy` shipped** (this PR, third and last of the stacked PRs,
306
+ branched fresh off `main` after PR 254 merged): a SINGLETON — `fetchLive` never returns
307
+ `undefined` (`RouteGetPolicyTreeError` has no `NotFound` case), so this resource has no create
308
+ or delete in the sense every sibling resource does, only adopt-and-update. `destroy` never
309
+ calls `routeResetPolicyTree` without an explicit `allowReset: true` prop, a gate independent of
310
+ (and in addition to) `defaultRemovalPolicy: 'retain'` — two gates, since resetting wipes the
311
+ WHOLE instance's routing back to Grafana's bare default, not one object. The tree is opaque
312
+ `Record<string, unknown>` (same reasoning as `mute-timing.ts`'s `timeIntervals` — Grafana's
313
+ matcher-expression types are complex unions this family does not hand-model), compared via
314
+ `subset-match.ts` for Grafana-injected-default tolerance, with route order significant. The
315
+ same dropped-item warning kit PR 254 introduced for rule groups is mirrored here for dropped
316
+ routes. Full detail: [grafana-notification-policy.md](./grafana-notification-policy.md).
317
+
318
+ This completes the alerting-provisioning family this project set out to cover.
293
319
  - ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
294
320
  (H1), and credentials come from an explicit env var NAME at call time rather than an
295
321
  `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.36.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
+ });