@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.
- 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/alerting-provenance.d.ts +80 -0
- package/dist/grafana/alerting-provenance.d.ts.map +1 -0
- package/dist/grafana/contact-point-fixtures.d.ts +28 -0
- package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
- package/dist/grafana/contact-point.d.ts +99 -0
- package/dist/grafana/contact-point.d.ts.map +1 -0
- package/dist/grafana/dashboard-model.d.ts +8 -38
- package/dist/grafana/dashboard-model.d.ts.map +1 -1
- package/dist/grafana/datasource.d.ts +2 -9
- package/dist/grafana/datasource.d.ts.map +1 -1
- package/dist/grafana/index.d.ts +11 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +339 -80
- package/dist/grafana/index.js.map +16 -7
- package/dist/grafana/message-template.d.ts +70 -0
- package/dist/grafana/message-template.d.ts.map +1 -0
- package/dist/grafana/mute-timing.d.ts +112 -0
- package/dist/grafana/mute-timing.d.ts.map +1 -0
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/secret-refs.d.ts +19 -0
- package/dist/grafana/secret-refs.d.ts.map +1 -0
- package/dist/grafana/subset-match.d.ts +2 -0
- package/dist/grafana/subset-match.d.ts.map +1 -0
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +3 -1
- package/dist/linux/index.js.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 +147 -0
- package/docs/grafana.md +6 -3
- package/docs/upstream-conformance.md +38 -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/alerting-provenance.ts +109 -0
- package/src/grafana/contact-point-fixtures.ts +25 -0
- package/src/grafana/contact-point-provenance.test.ts +113 -0
- package/src/grafana/contact-point-secrets.test.ts +117 -0
- package/src/grafana/contact-point.test.ts +123 -0
- package/src/grafana/contact-point.ts +179 -0
- package/src/grafana/dashboard-model.ts +8 -57
- package/src/grafana/datasource.ts +5 -27
- package/src/grafana/index.ts +31 -4
- package/src/grafana/message-template.test.ts +212 -0
- package/src/grafana/message-template.ts +114 -0
- package/src/grafana/mute-timing.test.ts +227 -0
- package/src/grafana/mute-timing.ts +160 -0
- package/src/grafana/providers.ts +8 -0
- package/src/grafana/secret-refs.ts +48 -0
- package/src/grafana/subset-match.ts +71 -0
- package/src/linux/index-export.test.ts +25 -0
- package/src/linux/index.ts +15 -1
- 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.
|
|
@@ -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
|
-
-
|
|
130
|
-
|
|
131
|
-
|
|
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** (
|
|
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.
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
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
|
+
});
|