@homeflare/alchemy 0.33.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 (86) 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/alerting-provenance.d.ts +80 -0
  10. package/dist/grafana/alerting-provenance.d.ts.map +1 -0
  11. package/dist/grafana/contact-point-fixtures.d.ts +28 -0
  12. package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
  13. package/dist/grafana/contact-point.d.ts +99 -0
  14. package/dist/grafana/contact-point.d.ts.map +1 -0
  15. package/dist/grafana/dashboard-model.d.ts +8 -38
  16. package/dist/grafana/dashboard-model.d.ts.map +1 -1
  17. package/dist/grafana/datasource.d.ts +2 -9
  18. package/dist/grafana/datasource.d.ts.map +1 -1
  19. package/dist/grafana/index.d.ts +11 -4
  20. package/dist/grafana/index.d.ts.map +1 -1
  21. package/dist/grafana/index.js +339 -80
  22. package/dist/grafana/index.js.map +16 -7
  23. package/dist/grafana/message-template.d.ts +70 -0
  24. package/dist/grafana/message-template.d.ts.map +1 -0
  25. package/dist/grafana/mute-timing.d.ts +112 -0
  26. package/dist/grafana/mute-timing.d.ts.map +1 -0
  27. package/dist/grafana/providers.d.ts +1 -1
  28. package/dist/grafana/providers.d.ts.map +1 -1
  29. package/dist/grafana/secret-refs.d.ts +19 -0
  30. package/dist/grafana/secret-refs.d.ts.map +1 -0
  31. package/dist/grafana/subset-match.d.ts +2 -0
  32. package/dist/grafana/subset-match.d.ts.map +1 -0
  33. package/dist/linux/index.d.ts +1 -1
  34. package/dist/linux/index.d.ts.map +1 -1
  35. package/dist/linux/index.js +3 -1
  36. package/dist/linux/index.js.map +1 -1
  37. package/dist/proxmox/index.js +349 -287
  38. package/dist/proxmox/index.js.map +8 -7
  39. package/dist/proxmox/network-apply-read.d.ts +12 -0
  40. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  41. package/dist/proxmox/network-apply.d.ts +2 -0
  42. package/dist/proxmox/network-apply.d.ts.map +1 -1
  43. package/dist/proxmox/node-network-form.d.ts +86 -54
  44. package/dist/proxmox/node-network-form.d.ts.map +1 -1
  45. package/dist/proxmox/node-network-wire.d.ts +100 -0
  46. package/dist/proxmox/node-network-wire.d.ts.map +1 -0
  47. package/dist/proxmox/node-network.d.ts +21 -106
  48. package/dist/proxmox/node-network.d.ts.map +1 -1
  49. package/docs/grafana-alerting-rules.md +143 -0
  50. package/docs/grafana-alerting.md +147 -0
  51. package/docs/grafana.md +6 -3
  52. package/docs/upstream-conformance.md +38 -4
  53. package/package.json +1 -1
  54. package/src/grafana/alert-rule-group-drop-warning.test.ts +107 -0
  55. package/src/grafana/alert-rule-group-drop-warning.ts +41 -0
  56. package/src/grafana/alert-rule-group-fixtures.ts +46 -0
  57. package/src/grafana/alert-rule-group-model.test.ts +75 -0
  58. package/src/grafana/alert-rule-group-model.ts +66 -0
  59. package/src/grafana/alert-rule-group-provenance.test.ts +116 -0
  60. package/src/grafana/alert-rule-group.test.ts +164 -0
  61. package/src/grafana/alert-rule-group.ts +231 -0
  62. package/src/grafana/alerting-provenance.ts +109 -0
  63. package/src/grafana/contact-point-fixtures.ts +25 -0
  64. package/src/grafana/contact-point-provenance.test.ts +113 -0
  65. package/src/grafana/contact-point-secrets.test.ts +117 -0
  66. package/src/grafana/contact-point.test.ts +123 -0
  67. package/src/grafana/contact-point.ts +179 -0
  68. package/src/grafana/dashboard-model.ts +8 -57
  69. package/src/grafana/datasource.ts +5 -27
  70. package/src/grafana/index.ts +31 -4
  71. package/src/grafana/message-template.test.ts +212 -0
  72. package/src/grafana/message-template.ts +114 -0
  73. package/src/grafana/mute-timing.test.ts +227 -0
  74. package/src/grafana/mute-timing.ts +160 -0
  75. package/src/grafana/providers.ts +8 -0
  76. package/src/grafana/secret-refs.ts +48 -0
  77. package/src/grafana/subset-match.ts +71 -0
  78. package/src/linux/index-export.test.ts +25 -0
  79. package/src/linux/index.ts +15 -1
  80. package/src/proxmox/constraints-host-forms.test.ts +2 -2
  81. package/src/proxmox/network-apply-read.ts +12 -0
  82. package/src/proxmox/network-apply.ts +2 -0
  83. package/src/proxmox/node-network-form.ts +93 -99
  84. package/src/proxmox/node-network-read-failure.test.ts +206 -0
  85. package/src/proxmox/node-network-wire.ts +239 -0
  86. 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.
@@ -0,0 +1,147 @@
1
+ # Grafana alerting provisioning — `@homeflare/alchemy/grafana`
2
+
3
+ Declares Grafana's alerting-provisioning objects (decision 40: every internal alert lands in one
4
+ place). Ships in three stacked PRs, in the order this file's own history records:
5
+
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
11
+ contact points, mute timings and templates by name).
12
+
13
+ All three ship on the same `@distilled.cloud/grafana@0.2.0` operations
14
+ [grafana.md](./grafana.md#the-sdk-gap--history-and-what-shipped-on-top-of-the-fix) already
15
+ unblocked (`skipDeprecated` patched false for the 32 alerting-provisioning-write operations). No
16
+ live call was made writing this PR — see "Measured vs inferred" below.
17
+
18
+ ## Foreign-provenance refusal — shared, `alerting-provenance.ts`
19
+
20
+ Every resource in this family reads a `provenance` string off what Grafana returns and refuses to
21
+ write when it names an owner other than this API. **Measured against Grafana's own source**
22
+ (`pkg/services/ngalert/models`, not re-fetched live): `ProvenanceNone = ""` (unclaimed),
23
+ `ProvenanceAPI = "api"` (this API's own writes), `ProvenanceFile = "file"`,
24
+ `ProvenanceConvertedPrometheus = "converted_prometheus"`. `alerting-provenance.ts`'s
25
+ `WRITABLE_PROVENANCES` is an **allowlist** of `{"", "api"}`, not a denylist of the two named
26
+ foreign values — an unnamed future provenance source refuses by default, the safer failure
27
+ direction for a control-plane write. Corroborated by Grafana's docs
28
+ (grafana.com/docs/grafana/v13.1/alerting/set-up/provision-alerting-resources/http-api-provisioning/),
29
+ whose example payloads show exactly `"api"` and `"file"`.
30
+
31
+ This is **defense in depth, not the only guard**: Grafana's alerting provisioning API also enforces
32
+ this server-side (grafana/grafana#103597, "Unable to delete file provisioned alert rules," reports
33
+ the block firing) — unlike the classic folder/dashboard write API `provisioned.ts` guards, which
34
+ has no such server-side check at all. `create` is never at risk for any resource below: it only
35
+ runs when `fetchLive` already found nothing.
36
+
37
+ ⛔ **A missing `provenance` field is treated as foreign, never as `ProvenanceNone` — fixed after an
38
+ adversarial review of this PR found the guard failing OPEN.** The first version mapped
39
+ `live.provenance ?? ''`, collapsing "the field is absent" into "the field is explicitly empty" —
40
+ both writable. `isForeignProvenance`/`refuseIfForeignProvenance` now keep `undefined` as its own
41
+ value: only a field Grafana explicitly reports as `""` or `"api"` is writable. Every resource below
42
+ has a test pinning all three cases (missing refuses, explicit `""` is writable, `"file"` refuses).
43
+
44
+ `X-Disable-Provenance` is **never set** by any resource in this family. Every create/update
45
+ therefore leaves the object at `provenance: "api"` — Grafana's own default for an API write, which
46
+ blocks UI edits afterward. Deliberate: an object this family declares should stay owned by the
47
+ declaration, the "one owner per resource" posture `provisioned.ts` already established for
48
+ folders/dashboards.
49
+
50
+ ## `Grafana.ContactPoint`
51
+
52
+ ```ts
53
+ import { GrafanaContactPoint, grafanaProviders } from '@homeflare/alchemy/grafana';
54
+
55
+ export class OncallSlack extends GrafanaContactPoint('oncall-slack', {
56
+ uid: 'slack-oncall',
57
+ name: 'On-call Slack',
58
+ type: 'slack',
59
+ settings: { recipient: '#oncall' },
60
+ secureSettingsRefs: { url: 'GRAFANA_SLACK_WEBHOOK_URL_ENV' },
61
+ }) {}
62
+ ```
63
+
64
+ - **No by-uid GET route exists** — measured: `services/grafana.ts` has only `routeGetContactpoints`
65
+ (list, optional `name` filter) and its export sibling. Narrowing that GET to the declared `name`
66
+ is not safe either: `name` is a renameable grouping label (Grafana's own doc: "used as grouping
67
+ key in the UI"), so `fetchLive` lists every contact point unfiltered and matches `uid`
68
+ client-side — the same ambiguity `resource.ts`'s header and netbox's `soleMatch` name.
69
+ - **Secrets never appear as a literal prop (S25).** A secure `settings` key (a webhook URL, an API
70
+ key, …) is named in `secureSettingsRefs` (field -> env var NAME), resolved fresh inside
71
+ `create`/`update`'s own effect and merged into `settings` only there. Grafana's plain GET has no
72
+ `decrypt` option (only the export route does, org-admin only) — a secure key always comes back
73
+ redacted, so `matches` excludes every `secureSettingsRefs` key from comparison entirely, even if
74
+ `settings` also names it with a stale placeholder value (`contact-point.test.ts`'s own test for
75
+ this). Non-secret `settings` compares via `subset-match.ts`'s `declaredContentMatches`, tolerating
76
+ a default Grafana injects that the declaration never mentioned.
77
+
78
+ ## `Grafana.MuteTiming`
79
+
80
+ ```ts
81
+ import { GrafanaMuteTiming, grafanaProviders } from '@homeflare/alchemy/grafana';
82
+
83
+ export class Weekends extends GrafanaMuteTiming('weekends-mute', {
84
+ name: 'weekends',
85
+ timeIntervals: [{ weekdays: ['saturday', 'sunday'] }],
86
+ }) {}
87
+ ```
88
+
89
+ - ⚠️ **A genuine, surprising SDK finding, worth restating here.** The generated `MuteTimeInterval`
90
+ type declares only `name`/`time_intervals` — no `provenance`, no `version`, unlike every other
91
+ type this family reads. A first pass assumed the data was gone; empirically it is not:
92
+ `@distilled.cloud/core`'s response decoding is `JSON.parse` + a key-rename pass, never a strict
93
+ schema decode — any key a type doesn't model passes through **verbatim** (`protocol-http.ts`'s
94
+ `mapKeys`, confirmed by calling the real operation with a `provenance`/`version`-bearing response
95
+ through this family's own fake-Grafana harness). `mute-timing.ts` reads both through a small
96
+ local `& { provenance?: string; version?: string }` intersection rather than the SDK's own type.
97
+ The same passthrough applies outbound: `timeIntervals` is typed as an opaque
98
+ `Record<string, unknown>[]`, not the SDK's own too-narrow `TimeInterval` (which is missing the
99
+ real Alertmanager fields — `weekdays`/`times`/`months`/`days_of_month`/`years`/`location` — for
100
+ the same reason), and a real weekday/time entry reaches the wire unchanged. Tracked as a real SDK
101
+ type-generation gap in `docs/upstream-conformance.md` (the S22 fix route) — not a data-loss gap.
102
+ - **`version` rides on `destroy`, not `update`** — measured: `RouteDeleteMuteTimingRequest.version`
103
+ exists for optimistic concurrency; `RoutePutMuteTimingRequest` has no `version` field at all, a
104
+ real Grafana API asymmetry between the two write routes.
105
+
106
+ ## `Grafana.MessageTemplate`
107
+
108
+ ```ts
109
+ import { GrafanaMessageTemplate, grafanaProviders } from '@homeflare/alchemy/grafana';
110
+
111
+ export class SlackBody extends GrafanaMessageTemplate('slack-body-template', {
112
+ name: 'slack-body',
113
+ template: '{{ define "slack-body" }}...{{ end }}',
114
+ }) {}
115
+ ```
116
+
117
+ - **Create and update are the same wire call** — measured: no `routePostTemplate` exists, only
118
+ `routePutTemplate` (`PUT /v1/provisioning/templates/{name}`, upsert by name). Kept as two spec
119
+ functions anyway so the shared engine's `diff` reports a content change as `update`, not the
120
+ `replace` it shows for any resource whose `spec.update` is `undefined` (`resource.ts`).
121
+ `version` rides on `update` when Grafana reports one, for optimistic concurrency.
122
+
123
+ ## Tests
124
+
125
+ Each resource's `*.test.ts` uses the family's `fake-grafana.ts` harness (the real distilled
126
+ protocol, only wire responses faked) and proves: a GET never carries a body; a foreign-provenance
127
+ object — an explicit `"file"` OR a MISSING `provenance` field — refuses update/destroy with no write
128
+ sent, while an explicit `""` is writable; a transient failure (401/403) propagates rather than
129
+ folding to absent; an unchanged declaration is a noop despite Grafana injecting an extra field the
130
+ declaration never mentioned; and — `Grafana.ContactPoint` only — a secret value never appears in the
131
+ attributes a real `reconcile` returns (driven end to end through create and the read-back
132
+ `resource.ts` always does, not just asserted absent from a declaration that never held it).
133
+ `Grafana.ContactPoint`'s tests are split three ways (`contact-point.test.ts`/`-secrets.test.ts`/
134
+ `-provenance.test.ts`, sharing fixtures from `contact-point-fixtures.ts`) to stay under the house's
135
+ 250-line file cap.
136
+
137
+ ## Measured vs inferred (no live call in this PR)
138
+
139
+ **Measured**, against the SDK's generated types and/or Grafana's own docs: the provenance enum and
140
+ its values; the absence of a by-uid contact-point GET; the create/update PUT-only shape for
141
+ templates and the `version`-on-destroy-only shape for mute timings; the response-decode
142
+ verbatim-passthrough behavior (traced into `@distilled.cloud/core`'s own source and confirmed
143
+ against the real operation through this family's fake-Grafana harness — as close to "measured" as
144
+ this PR gets without a live Grafana instance). **Inferred, not measured live:** whether Grafana's
145
+ real server-side provenance enforcement covers every write route this family calls the same way (only
146
+ the alert-rule-deletion case is corroborated by a filed issue); whether a genuine mute-timing
147
+ `version` conflict on delete surfaces as a typed `Conflict` in practice.
package/docs/grafana.md CHANGED
@@ -126,8 +126,11 @@ export class TeslaMateDatasource extends GrafanaDatasource('teslamate-datasource
126
126
 
127
127
  - **`Grafana.Folder` and `Grafana.Dashboard` ship** — see
128
128
  [grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
129
- - **Alert rules, contact points, notification policies, mute timings.** The SDK-level blocker is
130
- fixed (above); no house `Resource` calls these operations yet — follow-up work, not blocked on
131
- anything.
129
+ - **`Grafana.ContactPoint`, `Grafana.MuteTiming` and `Grafana.MessageTemplate` ship** — see
130
+ [grafana-alerting.md](./grafana-alerting.md), not repeated here.
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.
132
135
  - **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
133
136
  still carry (see upstream-conformance.md); a maintainer decision on the ownership-check shape.
@@ -261,13 +261,47 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
261
261
  `@homeflare/distilled-netbox` established. `Grafana.Datasource`'s props/attributes/generated code
262
262
  are unchanged (diffed operation-by-operation against the prior commit).
263
263
 
264
- ✅ **`Grafana.Folder`/`Grafana.Dashboard` shipped** (this PR, on top of the same
264
+ ✅ **`Grafana.Folder`/`Grafana.Dashboard` shipped** (PR 245, on top of the same
265
265
  `@distilled.cloud/grafana` operations S22 unblocked) — the uid-required doctrine, the
266
266
  provisioned-object refusal, the parentUid-is-create-only refusal and the volatile-field
267
267
  normalization that makes an unchanged dashboard a true noop are all in
268
- [grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here. **Still open,
269
- and not this PR's scope:** `Grafana.AlertRule`/`ContactPoint`/`NotificationPolicy`/`MuteTiming`
270
- — follow-up PRs, no longer blocked on the SDK.
268
+ [grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
269
+
270
+ ✅ **`Grafana.ContactPoint`/`Grafana.MuteTiming`/`Grafana.MessageTemplate` shipped** (kit PR
271
+ 250, first of three stacked PRs — `Grafana.AlertRuleGroup` then `Grafana.NotificationPolicy`
272
+ follow):
273
+ the foreign-provenance refusal (`alerting-provenance.ts`, an allowlist of Grafana's own
274
+ `ProvenanceNone`/`ProvenanceAPI` values, measured against `pkg/services/ngalert/models` and
275
+ Grafana's v13.1 docs) and the secret-settings-ref seam (`secret-refs.ts`, shared with
276
+ `Grafana.Datasource`) are both new shared building blocks — full detail:
277
+ [grafana-alerting.md](./grafana-alerting.md).
278
+ - 🔴 **New SDK gap found, not fixed here:** `MuteTimeInterval`'s generated type has no
279
+ `provenance`/`version` fields, and the SDK's own `TimeInterval` type is missing the real
280
+ Alertmanager time-interval fields (`weekdays`/`times`/`months`/etc.) entirely — Grafana's real
281
+ API returns and accepts all of them. NOT a data-loss gap: `@distilled.cloud/core`'s response
282
+ decoding never runs a strict schema decode (`JSON.parse` + a key-rename pass that leaves any
283
+ unmodeled key verbatim — `protocol-http.ts`'s `mapKeys`, confirmed by driving the real
284
+ operation through this family's fake-Grafana harness), so `mute-timing.ts` reads/writes both
285
+ through a locally-widened type instead. Same S22 fix route as the other named gaps
286
+ (regenerate the distilled clone's spec, copy forward) — not attempted here, since
287
+ `packages/distilled-grafana/src/` is vendored and never hand-edited.
288
+ - Measured, read-only, same census as PR 245's: `teslamate-grafana` has no contact points, mute
289
+ timings or templates configured either (consistent with the alerting-provisioning gap having
290
+ been real, not merely unexploited).
291
+
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.
271
305
  - ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
272
306
  (H1), and credentials come from an explicit env var NAME at call time rather than an
273
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.33.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
+ });