@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.
- 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 +6 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +355 -139
- package/dist/grafana/index.js.map +14 -8
- package/dist/grafana/notification-policy-drop-warning.d.ts +27 -0
- package/dist/grafana/notification-policy-drop-warning.d.ts.map +1 -0
- package/dist/grafana/notification-policy-fixtures.d.ts +34 -0
- package/dist/grafana/notification-policy-fixtures.d.ts.map +1 -0
- package/dist/grafana/notification-policy-model.d.ts +53 -0
- package/dist/grafana/notification-policy-model.d.ts.map +1 -0
- package/dist/grafana/notification-policy.d.ts +57 -0
- package/dist/grafana/notification-policy.d.ts.map +1 -0
- 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 +7 -4
- package/docs/grafana-notification-policy.md +127 -0
- package/docs/grafana.md +12 -6
- package/docs/upstream-conformance.md +30 -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 +18 -4
- package/src/grafana/notification-policy-drop-warning.test.ts +163 -0
- package/src/grafana/notification-policy-drop-warning.ts +60 -0
- package/src/grafana/notification-policy-fixtures.ts +27 -0
- package/src/grafana/notification-policy-model.test.ts +104 -0
- package/src/grafana/notification-policy-model.ts +100 -0
- package/src/grafana/notification-policy-refusals.test.ts +119 -0
- package/src/grafana/notification-policy.test.ts +86 -0
- package/src/grafana/notification-policy.ts +191 -0
- package/src/grafana/providers.ts +4 -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,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.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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** (
|
|
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,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
|
-
**
|
|
292
|
-
|
|
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.
|
|
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
|
+
});
|