@homeflare/alchemy 0.35.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/index.d.ts +5 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +204 -90
- package/dist/grafana/index.js.map +7 -4
- 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/docs/grafana-alerting.md +3 -2
- package/docs/grafana-notification-policy.md +127 -0
- package/docs/grafana.md +12 -8
- package/docs/upstream-conformance.md +19 -5
- package/package.json +1 -1
- package/src/grafana/index.ts +11 -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 +2 -0
|
@@ -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,15 +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` 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.
|
|
135
139
|
- **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
|
|
136
140
|
still carry (see upstream-conformance.md); a maintainer decision on the ownership-check shape.
|
|
@@ -289,7 +289,7 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
|
|
|
289
289
|
timings or templates configured either (consistent with the alerting-provisioning gap having
|
|
290
290
|
been real, not merely unexploited).
|
|
291
291
|
|
|
292
|
-
✅ **`Grafana.AlertRuleGroup` shipped** (
|
|
292
|
+
✅ **`Grafana.AlertRuleGroup` shipped** (kit PR 254, second of three, branched fresh off `main`
|
|
293
293
|
after PR 250 merged): group, not per-rule, is the unit — `PUT .../rule-groups/{Group}` owns
|
|
294
294
|
evaluation `interval` and the full ordered `rules[]` together, which per-rule operations
|
|
295
295
|
cannot express (no `interval` field, no reordering). Rule order is significant, proven by a
|
|
@@ -297,11 +297,25 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
|
|
|
297
297
|
the group type itself carries none) reuses `alerting-provenance.ts` unchanged, including the
|
|
298
298
|
missing-means-foreign fix from PR 250's review. A declared group whose folder does not exist
|
|
299
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.
|
|
301
|
-
|
|
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).
|
|
302
304
|
|
|
303
|
-
|
|
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.
|
|
305
319
|
- ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
|
|
306
320
|
(H1), and credentials come from an explicit env var NAME at call time rather than an
|
|
307
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",
|
package/src/grafana/index.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* depend on.
|
|
8
8
|
*
|
|
9
9
|
* `Grafana.Datasource`, `Grafana.Folder`, `Grafana.Dashboard`, `Grafana.ContactPoint`,
|
|
10
|
-
* `Grafana.MuteTiming`, `Grafana.MessageTemplate
|
|
11
|
-
* `Grafana.NotificationPolicy`
|
|
12
|
-
*
|
|
13
|
-
* docs/grafana-
|
|
10
|
+
* `Grafana.MuteTiming`, `Grafana.MessageTemplate`, `Grafana.AlertRuleGroup` and
|
|
11
|
+
* `Grafana.NotificationPolicy` all ship here — see docs/grafana.md, docs/grafana-folder-dashboard.md,
|
|
12
|
+
* docs/grafana-alerting.md, docs/grafana-alerting-rules.md and
|
|
13
|
+
* docs/grafana-notification-policy.md for what shipped.
|
|
14
14
|
*/
|
|
15
15
|
export {
|
|
16
16
|
type AlertRuleGroupAttributes,
|
|
@@ -19,6 +19,13 @@ export {
|
|
|
19
19
|
GrafanaAlertRuleGroup,
|
|
20
20
|
GrafanaAlertRuleGroupProvider,
|
|
21
21
|
} from './alert-rule-group.ts';
|
|
22
|
+
export {
|
|
23
|
+
type NotificationPolicyAttributes,
|
|
24
|
+
type NotificationPolicyProps,
|
|
25
|
+
type PolicyRoute,
|
|
26
|
+
GrafanaNotificationPolicy,
|
|
27
|
+
GrafanaNotificationPolicyProvider,
|
|
28
|
+
} from './notification-policy.ts';
|
|
22
29
|
export {
|
|
23
30
|
type DatasourceAttributes,
|
|
24
31
|
type DatasourceProps,
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `warnOnDroppedRoutes` — pure logic, then wired through `diff`/`update` on a fake Grafana. Mirrors
|
|
3
|
+
* `alert-rule-group-drop-warning.test.ts`: captures `Effect.logWarning` with a `Logger`, the house
|
|
4
|
+
* pattern `caddy/config-format-warning.test.ts` established.
|
|
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 { PATH, liveTree, props } from './notification-policy-fixtures.ts';
|
|
10
|
+
import { handlers, spec } from './notification-policy.ts';
|
|
11
|
+
import { warnOnDroppedRoutes } from './notification-policy-drop-warning.ts';
|
|
12
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
13
|
+
import { grafanaOperations } from './resource.ts';
|
|
14
|
+
|
|
15
|
+
const withWarnings = async <A>(effect: Effect.Effect<A, unknown, never>) => {
|
|
16
|
+
const warnings: string[] = [];
|
|
17
|
+
const collect = Logger.make((options) => {
|
|
18
|
+
if (options.logLevel === 'Warn') warnings.push(String(options.message));
|
|
19
|
+
});
|
|
20
|
+
const value = await Effect.runPromise(effect.pipe(Effect.provide(Logger.layer([collect]))));
|
|
21
|
+
return { value, warnings };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('warnOnDroppedRoutes — pure', () => {
|
|
25
|
+
test('logs nothing when the declared tree already has every live route', async () => {
|
|
26
|
+
const tree = { receiver: 'default', routes: [{ receiver: 'pagerduty' }] };
|
|
27
|
+
const { warnings } = await withWarnings(warnOnDroppedRoutes(tree, tree));
|
|
28
|
+
expect(warnings).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('names a dropped nested route by receiver and path', async () => {
|
|
32
|
+
const declared = { receiver: 'default', routes: [{ receiver: 'pagerduty' }] };
|
|
33
|
+
const live = {
|
|
34
|
+
receiver: 'default',
|
|
35
|
+
routes: [{ receiver: 'pagerduty' }, { matchers: ['team=x'], receiver: 'slack' }],
|
|
36
|
+
};
|
|
37
|
+
const { warnings } = await withWarnings(warnOnDroppedRoutes(declared, live));
|
|
38
|
+
expect(warnings).toHaveLength(1);
|
|
39
|
+
expect(warnings[0]).toContain('slack (root.1)');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a changed root receiver is never reported as "dropped" — root is replaced, not removed', async () => {
|
|
43
|
+
const { warnings } = await withWarnings(
|
|
44
|
+
warnOnDroppedRoutes({ receiver: 'new' }, { receiver: 'old' }),
|
|
45
|
+
);
|
|
46
|
+
expect(warnings).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('the same two routes reordered logs nothing — reordering is not dropping', async () => {
|
|
50
|
+
// ⛔ THE POINT: `matches()` (subset-match's position-significant array rule) still shows
|
|
51
|
+
// `update` for a reorder — this warning stays silent because nothing is actually REMOVED,
|
|
52
|
+
// complementing rather than duplicating that signal.
|
|
53
|
+
const a = { receiver: 'pagerduty' };
|
|
54
|
+
const b = { receiver: 'slack' };
|
|
55
|
+
const { warnings } = await withWarnings(
|
|
56
|
+
warnOnDroppedRoutes(
|
|
57
|
+
{ receiver: 'default', routes: [b, a] },
|
|
58
|
+
{ receiver: 'default', routes: [a, b] },
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
expect(warnings).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a duplicate route added live warns — the multiset fix, not the original Set collision', async () => {
|
|
65
|
+
// ⛔ THE REGRESSION AN ADVERSARIAL REVIEW OF THIS PR FOUND: the first version's `Set`-based
|
|
66
|
+
// identity collided this duplicate with the already-declared one and warned nothing, even
|
|
67
|
+
// though the PUT drops it. `severity=warning` -> `slack` is declared once; live has it TWICE.
|
|
68
|
+
const declared = {
|
|
69
|
+
receiver: 'default',
|
|
70
|
+
routes: [
|
|
71
|
+
{ matchers: ['severity=warning'], receiver: 'slack' },
|
|
72
|
+
{ matchers: ['severity=critical'], receiver: 'pagerduty' },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
const live = {
|
|
76
|
+
receiver: 'default',
|
|
77
|
+
routes: [
|
|
78
|
+
{ matchers: ['severity=warning'], receiver: 'slack' },
|
|
79
|
+
{ matchers: ['severity=critical'], receiver: 'pagerduty' },
|
|
80
|
+
{ matchers: ['severity=warning'], receiver: 'slack' },
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
const { warnings } = await withWarnings(warnOnDroppedRoutes(declared, live));
|
|
84
|
+
expect(warnings).toHaveLength(1);
|
|
85
|
+
expect(warnings[0]).toContain('REMOVES 1 route');
|
|
86
|
+
expect(warnings[0]).toContain('slack (root.2)');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('an identical route under a DIFFERENT (undeclared) parent still warns — distinct lineage', async () => {
|
|
90
|
+
const declared = {
|
|
91
|
+
receiver: 'default',
|
|
92
|
+
routes: [{ receiver: 'p1', routes: [{ receiver: 'slack' }] }],
|
|
93
|
+
};
|
|
94
|
+
const live = {
|
|
95
|
+
receiver: 'default',
|
|
96
|
+
routes: [
|
|
97
|
+
{ receiver: 'p1', routes: [{ receiver: 'slack' }] },
|
|
98
|
+
{ receiver: 'p2', routes: [{ receiver: 'slack' }] },
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
const { warnings } = await withWarnings(warnOnDroppedRoutes(declared, live));
|
|
102
|
+
expect(warnings).toHaveLength(1);
|
|
103
|
+
// ⛔ NOT root.0.0 (that one IS declared, under p1) — the dropped one is the p2 lineage's.
|
|
104
|
+
expect(warnings[0]).toContain('slack (root.1.0)');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('two declared duplicates vs two live duplicates warns nothing — counts match', async () => {
|
|
108
|
+
const pair = {
|
|
109
|
+
receiver: 'default',
|
|
110
|
+
routes: [{ receiver: 'slack' }, { receiver: 'slack' }],
|
|
111
|
+
};
|
|
112
|
+
const { warnings } = await withWarnings(warnOnDroppedRoutes(pair, pair));
|
|
113
|
+
expect(warnings).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('wired into diff and update', () => {
|
|
118
|
+
test('diff logs the dropped-route warning before anything is written', async () => {
|
|
119
|
+
const extra = { matchers: ['team=x'], receiver: 'slack' };
|
|
120
|
+
const withExtra = { ...liveTree, routes: [...liveTree.routes, extra] };
|
|
121
|
+
const fake = fakeGrafana((method, url) =>
|
|
122
|
+
method === 'GET' && url.pathname === PATH
|
|
123
|
+
? Response.json(withExtra)
|
|
124
|
+
: fakeFailure(500, 'diff should not write'),
|
|
125
|
+
);
|
|
126
|
+
const { warnings, value } = await withWarnings(
|
|
127
|
+
handlers
|
|
128
|
+
.diff({ news: props, output: { provenance: 'api', route: props.route } })
|
|
129
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))) as Effect.Effect<
|
|
130
|
+
unknown,
|
|
131
|
+
unknown,
|
|
132
|
+
never
|
|
133
|
+
>,
|
|
134
|
+
);
|
|
135
|
+
// ⚠️ NOT 'noop': subset-match's array rule requires EXACT length, so live carrying one MORE
|
|
136
|
+
// route than declared already disagrees on its own — the warning and the mismatch are the
|
|
137
|
+
// same signal here, not independent (see the file header on `matches` vs this warning).
|
|
138
|
+
expect(value).toEqual({ action: 'update' });
|
|
139
|
+
expect(warnings).toHaveLength(1);
|
|
140
|
+
expect(warnings[0]).toContain('slack');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('update logs the same warning right before the PUT that would drop the route', async () => {
|
|
144
|
+
const extra = { matchers: ['team=x'], receiver: 'slack' };
|
|
145
|
+
const withExtra = { ...liveTree, routes: [...liveTree.routes, extra] };
|
|
146
|
+
const fake = fakeGrafana((method, url) => {
|
|
147
|
+
if (method === 'GET' && url.pathname === PATH) return Response.json(withExtra);
|
|
148
|
+
if (method === 'PUT' && url.pathname === PATH) return Response.json(withExtra);
|
|
149
|
+
return fakeFailure(500, 'unexpected request');
|
|
150
|
+
});
|
|
151
|
+
const { warnings } = await withWarnings(
|
|
152
|
+
grafanaOperations(spec)
|
|
153
|
+
.reconcile({ route: { ...props.route, receiver: 'changed' } })
|
|
154
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))) as Effect.Effect<
|
|
155
|
+
unknown,
|
|
156
|
+
unknown,
|
|
157
|
+
never
|
|
158
|
+
>,
|
|
159
|
+
);
|
|
160
|
+
expect(warnings).toHaveLength(1);
|
|
161
|
+
expect(warnings[0]).toContain('slack');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The warning `Grafana.NotificationPolicy` logs when a whole-tree `PUT` would silently drop a live
|
|
3
|
+
* sub-route the declaration doesn't account for — same shape as `alert-rule-group-drop-warning.ts`
|
|
4
|
+
* (kit PR 254's own fix, after an adversarial review found the identical risk for rule groups):
|
|
5
|
+
* `PUT /v1/provisioning/policies` REPLACES the entire tree, so a nested route someone added
|
|
6
|
+
* out-of-band is removed with no error and no distinct `Diff` action to report it under.
|
|
7
|
+
*
|
|
8
|
+
* ★ `Effect.logWarning`, NEVER A REFUSAL — same reasoning as `alert-rule-group-drop-warning.ts`'s
|
|
9
|
+
* own header, and further upstream `proxmox/unreadable-read.ts`'s `unreadableWarning`. A
|
|
10
|
+
* whole-tree write dropping an out-of-band route is EXPECTED for a resource whose whole point is
|
|
11
|
+
* to own the tree — only a silent one is worth surfacing loudly.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ MULTISET COMPARISON, NOT SET MEMBERSHIP — fixed after an adversarial review of this PR found
|
|
14
|
+
* the first version's `Set`-based check collided ACROSS THE WHOLE TREE: any two nodes anywhere
|
|
15
|
+
* with identical own-fields shared one `Set` entry, so a duplicate route added out-of-band
|
|
16
|
+
* (e.g. a second `severity=warning -> slack` alongside an already-declared one) matched the
|
|
17
|
+
* existing entry and went unwarned even though the PUT would still drop it — worse than the
|
|
18
|
+
* documented "siblings only" limitation the original comment claimed. Every key from
|
|
19
|
+
* `FlatRoute.key` (ancestry-scoped — see that field's own doc) is now counted per side, and a
|
|
20
|
+
* `key` is only "dropped" for the amount its LIVE count exceeds its DECLARED count — N identical
|
|
21
|
+
* live occurrences under the same parent are dropped only past however many the declaration
|
|
22
|
+
* itself repeats, never all of them at once just because one is declared.
|
|
23
|
+
*/
|
|
24
|
+
import * as Effect from 'effect/Effect';
|
|
25
|
+
import { type PolicyRoute, flattenRoutes, normalizeRoute } from './notification-policy-model.ts';
|
|
26
|
+
|
|
27
|
+
const countByKey = (routes: ReadonlyArray<{ readonly key: string }>): Map<string, number> => {
|
|
28
|
+
const counts = new Map<string, number>();
|
|
29
|
+
for (const route of routes) counts.set(route.key, (counts.get(route.key) ?? 0) + 1);
|
|
30
|
+
return counts;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const warnOnDroppedRoutes = (
|
|
34
|
+
declared: PolicyRoute,
|
|
35
|
+
live: PolicyRoute,
|
|
36
|
+
): Effect.Effect<void> =>
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
// Root is never "dropped" — it is always replaced, not removed (the file header's own note).
|
|
39
|
+
const declaredNodes = flattenRoutes(normalizeRoute(declared)).filter((r) => r.path !== 'root');
|
|
40
|
+
const liveNodes = flattenRoutes(normalizeRoute(live)).filter((r) => r.path !== 'root');
|
|
41
|
+
const declaredCounts = countByKey(declaredNodes);
|
|
42
|
+
const liveCounts = countByKey(liveNodes);
|
|
43
|
+
|
|
44
|
+
const dropped: Array<{ readonly path: string; readonly receiver: string }> = [];
|
|
45
|
+
for (const [key, liveCount] of liveCounts) {
|
|
46
|
+
const extra = liveCount - (declaredCounts.get(key) ?? 0);
|
|
47
|
+
if (extra <= 0) continue;
|
|
48
|
+
// Report the LAST `extra` live occurrences of this key, by path — an arbitrary but
|
|
49
|
+
// deterministic choice among indistinguishable duplicates (multiset identity, no position).
|
|
50
|
+
const occurrences = liveNodes.filter((route) => route.key === key);
|
|
51
|
+
dropped.push(...occurrences.slice(-extra));
|
|
52
|
+
}
|
|
53
|
+
if (dropped.length === 0) return;
|
|
54
|
+
const names = dropped.map((route) => `${route.receiver} (${route.path})`).join(', ');
|
|
55
|
+
yield* Effect.logWarning(
|
|
56
|
+
`Grafana.NotificationPolicy: this update REMOVES ${dropped.length} route(s)/matcher(s) not ` +
|
|
57
|
+
`in the declaration: ${names}. Whole-tree replacement is by design — add them to the ` +
|
|
58
|
+
`declared tree to keep them.`,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures for the `notification-policy*.test.ts` files — split to keep each file under
|
|
3
|
+
* the house's 250-line cap. Deliberately NOT named `*.test.ts` — see `contact-point-fixtures.ts`'s
|
|
4
|
+
* own header for why.
|
|
5
|
+
*/
|
|
6
|
+
export const PATH = '/api/v1/provisioning/policies';
|
|
7
|
+
|
|
8
|
+
export const liveTree = {
|
|
9
|
+
group_by: ['alertname'],
|
|
10
|
+
provenance: 'api',
|
|
11
|
+
receiver: 'default-receiver',
|
|
12
|
+
routes: [
|
|
13
|
+
{ matchers: ['severity=critical'], receiver: 'pagerduty' },
|
|
14
|
+
{ matchers: ['severity=warning'], receiver: 'slack' },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const declaredRoute = {
|
|
19
|
+
group_by: ['alertname'],
|
|
20
|
+
receiver: 'default-receiver',
|
|
21
|
+
routes: [
|
|
22
|
+
{ matchers: ['severity=critical'], receiver: 'pagerduty' },
|
|
23
|
+
{ matchers: ['severity=warning'], receiver: 'slack' },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const props = { route: declaredRoute };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `normalizeRoute`/`flattenRoutes` in isolation — no SDK, no fake, no Effect. Mirrors
|
|
3
|
+
* `alert-rule-group-model.test.ts`. Proves `provenance` is stripped recursively, that
|
|
4
|
+
* `flattenRoutes` walks the tree depth-first with dotted-index paths, and — the fix an adversarial
|
|
5
|
+
* review of this PR made necessary — that its `key` is ancestry-scoped: shared by siblings with
|
|
6
|
+
* identical own-fields (so a multiset count, not set membership, is what distinguishes them),
|
|
7
|
+
* distinct across different parents, and unaffected by sibling reordering.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { flattenRoutes, normalizeRoute } from './notification-policy-model.ts';
|
|
11
|
+
|
|
12
|
+
describe('normalizeRoute', () => {
|
|
13
|
+
test('strips provenance at the root', () => {
|
|
14
|
+
expect(normalizeRoute({ provenance: 'api', receiver: 'default' })).toEqual({
|
|
15
|
+
receiver: 'default',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('strips provenance recursively, at every nested route', () => {
|
|
20
|
+
const tree = {
|
|
21
|
+
provenance: 'api',
|
|
22
|
+
receiver: 'default',
|
|
23
|
+
routes: [
|
|
24
|
+
{
|
|
25
|
+
provenance: 'api',
|
|
26
|
+
receiver: 'pagerduty',
|
|
27
|
+
routes: [{ provenance: 'api', receiver: 'slack' }],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
expect(normalizeRoute(tree)).toEqual({
|
|
32
|
+
receiver: 'default',
|
|
33
|
+
routes: [{ receiver: 'pagerduty', routes: [{ receiver: 'slack' }] }],
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a declared tree and a live tree that only disagree on provenance normalize equal', () => {
|
|
38
|
+
const declared = { receiver: 'default', routes: [{ receiver: 'pagerduty' }] };
|
|
39
|
+
const live = {
|
|
40
|
+
provenance: 'api',
|
|
41
|
+
receiver: 'default',
|
|
42
|
+
routes: [{ provenance: 'api', receiver: 'pagerduty' }],
|
|
43
|
+
};
|
|
44
|
+
expect(normalizeRoute(declared)).toEqual(normalizeRoute(live));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('leaves a real content change genuinely different', () => {
|
|
48
|
+
expect(normalizeRoute({ receiver: 'default' })).not.toEqual(
|
|
49
|
+
normalizeRoute({ receiver: 'other' }),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('flattenRoutes', () => {
|
|
55
|
+
test('walks the tree depth-first with dotted-index paths, root included', () => {
|
|
56
|
+
const tree = {
|
|
57
|
+
receiver: 'default',
|
|
58
|
+
routes: [{ receiver: 'pagerduty' }, { receiver: 'slack', routes: [{ receiver: 'nested' }] }],
|
|
59
|
+
};
|
|
60
|
+
const flat = flattenRoutes(tree);
|
|
61
|
+
expect(flat.map((r) => r.path)).toEqual(['root', 'root.0', 'root.1', 'root.1.0']);
|
|
62
|
+
expect(flat.map((r) => r.receiver)).toEqual(['default', 'pagerduty', 'slack', 'nested']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a route with no receiver of its own reports '(inherited)'", () => {
|
|
66
|
+
expect(flattenRoutes({ matchers: ['x'] })[0]?.receiver).toBe('(inherited)');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("each entry's key excludes its own routes children — they are separate entries", () => {
|
|
70
|
+
const tree = { receiver: 'default', routes: [{ receiver: 'child' }] };
|
|
71
|
+
const [root] = flattenRoutes(tree);
|
|
72
|
+
expect(root?.key).not.toContain('child');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('two siblings with identical own-fields under the SAME parent share one key', () => {
|
|
76
|
+
const tree = { receiver: 'default', routes: [{ receiver: 'slack' }, { receiver: 'slack' }] };
|
|
77
|
+
const [, first, second] = flattenRoutes(tree);
|
|
78
|
+
expect(first?.key).toBe(second?.key);
|
|
79
|
+
expect(first?.path).not.toBe(second?.path);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('an identical route under a DIFFERENT parent has a different key', () => {
|
|
83
|
+
const tree = {
|
|
84
|
+
receiver: 'default',
|
|
85
|
+
routes: [
|
|
86
|
+
{ receiver: 'p1', routes: [{ receiver: 'slack' }] },
|
|
87
|
+
{ receiver: 'p2', routes: [{ receiver: 'slack' }] },
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
const flat = flattenRoutes(tree);
|
|
91
|
+
const underP1 = flat.find((r) => r.path === 'root.0.0');
|
|
92
|
+
const underP2 = flat.find((r) => r.path === 'root.1.0');
|
|
93
|
+
expect(underP1?.key).not.toBe(underP2?.key);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('reordering siblings changes their path but not their key', () => {
|
|
97
|
+
const a = { receiver: 'pagerduty' };
|
|
98
|
+
const b = { receiver: 'slack' };
|
|
99
|
+
const forward = flattenRoutes({ receiver: 'default', routes: [a, b] });
|
|
100
|
+
const reversed = flattenRoutes({ receiver: 'default', routes: [b, a] });
|
|
101
|
+
const keysOf = (flat: typeof forward) => flat.map((r) => r.key).sort();
|
|
102
|
+
expect(keysOf(forward)).toEqual(keysOf(reversed));
|
|
103
|
+
});
|
|
104
|
+
});
|