@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.
- package/dist/grafana/alert-rule-group-drop-warning.d.ts +25 -0
- package/dist/grafana/alert-rule-group-drop-warning.d.ts.map +1 -0
- package/dist/grafana/alert-rule-group-fixtures.d.ts +73 -0
- package/dist/grafana/alert-rule-group-fixtures.d.ts.map +1 -0
- package/dist/grafana/alert-rule-group-model.d.ts +28 -0
- package/dist/grafana/alert-rule-group-model.d.ts.map +1 -0
- package/dist/grafana/alert-rule-group.d.ts +60 -0
- package/dist/grafana/alert-rule-group.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +5 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +241 -139
- package/dist/grafana/index.js.map +11 -8
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/proxmox/index.js +349 -287
- package/dist/proxmox/index.js.map +8 -7
- package/dist/proxmox/network-apply-read.d.ts +12 -0
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/network-apply.d.ts +2 -0
- package/dist/proxmox/network-apply.d.ts.map +1 -1
- package/dist/proxmox/node-network-form.d.ts +86 -54
- package/dist/proxmox/node-network-form.d.ts.map +1 -1
- package/dist/proxmox/node-network-wire.d.ts +100 -0
- package/dist/proxmox/node-network-wire.d.ts.map +1 -0
- package/dist/proxmox/node-network.d.ts +21 -106
- package/dist/proxmox/node-network.d.ts.map +1 -1
- package/docs/grafana-alerting-rules.md +143 -0
- package/docs/grafana-alerting.md +5 -3
- package/docs/grafana.md +4 -2
- package/docs/upstream-conformance.md +16 -4
- package/package.json +1 -1
- package/src/grafana/alert-rule-group-drop-warning.test.ts +107 -0
- package/src/grafana/alert-rule-group-drop-warning.ts +41 -0
- package/src/grafana/alert-rule-group-fixtures.ts +46 -0
- package/src/grafana/alert-rule-group-model.test.ts +75 -0
- package/src/grafana/alert-rule-group-model.ts +66 -0
- package/src/grafana/alert-rule-group-provenance.test.ts +116 -0
- package/src/grafana/alert-rule-group.test.ts +164 -0
- package/src/grafana/alert-rule-group.ts +231 -0
- package/src/grafana/index.ts +11 -4
- package/src/grafana/providers.ts +2 -0
- package/src/proxmox/constraints-host-forms.test.ts +2 -2
- package/src/proxmox/network-apply-read.ts +12 -0
- package/src/proxmox/network-apply.ts +2 -0
- package/src/proxmox/node-network-form.ts +93 -99
- package/src/proxmox/node-network-read-failure.test.ts +206 -0
- package/src/proxmox/node-network-wire.ts +239 -0
- 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.
|
package/docs/grafana-alerting.md
CHANGED
|
@@ -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.
|
|
7
|
-
|
|
8
|
-
|
|
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`
|
|
132
|
-
|
|
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** (
|
|
271
|
-
first of three stacked PRs — `Grafana.AlertRuleGroup` then `Grafana.NotificationPolicy`
|
|
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
|
-
|
|
292
|
-
|
|
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.
|
|
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
|
+
});
|