@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.
Files changed (65) hide show
  1. package/dist/grafana/alert-rule-group-drop-warning.d.ts +25 -0
  2. package/dist/grafana/alert-rule-group-drop-warning.d.ts.map +1 -0
  3. package/dist/grafana/alert-rule-group-fixtures.d.ts +73 -0
  4. package/dist/grafana/alert-rule-group-fixtures.d.ts.map +1 -0
  5. package/dist/grafana/alert-rule-group-model.d.ts +28 -0
  6. package/dist/grafana/alert-rule-group-model.d.ts.map +1 -0
  7. package/dist/grafana/alert-rule-group.d.ts +60 -0
  8. package/dist/grafana/alert-rule-group.d.ts.map +1 -0
  9. package/dist/grafana/index.d.ts +6 -4
  10. package/dist/grafana/index.d.ts.map +1 -1
  11. package/dist/grafana/index.js +355 -139
  12. package/dist/grafana/index.js.map +14 -8
  13. package/dist/grafana/notification-policy-drop-warning.d.ts +27 -0
  14. package/dist/grafana/notification-policy-drop-warning.d.ts.map +1 -0
  15. package/dist/grafana/notification-policy-fixtures.d.ts +34 -0
  16. package/dist/grafana/notification-policy-fixtures.d.ts.map +1 -0
  17. package/dist/grafana/notification-policy-model.d.ts +53 -0
  18. package/dist/grafana/notification-policy-model.d.ts.map +1 -0
  19. package/dist/grafana/notification-policy.d.ts +57 -0
  20. package/dist/grafana/notification-policy.d.ts.map +1 -0
  21. package/dist/grafana/providers.d.ts +1 -1
  22. package/dist/grafana/providers.d.ts.map +1 -1
  23. package/dist/proxmox/index.js +349 -287
  24. package/dist/proxmox/index.js.map +8 -7
  25. package/dist/proxmox/network-apply-read.d.ts +12 -0
  26. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  27. package/dist/proxmox/network-apply.d.ts +2 -0
  28. package/dist/proxmox/network-apply.d.ts.map +1 -1
  29. package/dist/proxmox/node-network-form.d.ts +86 -54
  30. package/dist/proxmox/node-network-form.d.ts.map +1 -1
  31. package/dist/proxmox/node-network-wire.d.ts +100 -0
  32. package/dist/proxmox/node-network-wire.d.ts.map +1 -0
  33. package/dist/proxmox/node-network.d.ts +21 -106
  34. package/dist/proxmox/node-network.d.ts.map +1 -1
  35. package/docs/grafana-alerting-rules.md +143 -0
  36. package/docs/grafana-alerting.md +7 -4
  37. package/docs/grafana-notification-policy.md +127 -0
  38. package/docs/grafana.md +12 -6
  39. package/docs/upstream-conformance.md +30 -4
  40. package/package.json +1 -1
  41. package/src/grafana/alert-rule-group-drop-warning.test.ts +107 -0
  42. package/src/grafana/alert-rule-group-drop-warning.ts +41 -0
  43. package/src/grafana/alert-rule-group-fixtures.ts +46 -0
  44. package/src/grafana/alert-rule-group-model.test.ts +75 -0
  45. package/src/grafana/alert-rule-group-model.ts +66 -0
  46. package/src/grafana/alert-rule-group-provenance.test.ts +116 -0
  47. package/src/grafana/alert-rule-group.test.ts +164 -0
  48. package/src/grafana/alert-rule-group.ts +231 -0
  49. package/src/grafana/index.ts +18 -4
  50. package/src/grafana/notification-policy-drop-warning.test.ts +163 -0
  51. package/src/grafana/notification-policy-drop-warning.ts +60 -0
  52. package/src/grafana/notification-policy-fixtures.ts +27 -0
  53. package/src/grafana/notification-policy-model.test.ts +104 -0
  54. package/src/grafana/notification-policy-model.ts +100 -0
  55. package/src/grafana/notification-policy-refusals.test.ts +119 -0
  56. package/src/grafana/notification-policy.test.ts +86 -0
  57. package/src/grafana/notification-policy.ts +191 -0
  58. package/src/grafana/providers.ts +4 -0
  59. package/src/proxmox/constraints-host-forms.test.ts +2 -2
  60. package/src/proxmox/network-apply-read.ts +12 -0
  61. package/src/proxmox/network-apply.ts +2 -0
  62. package/src/proxmox/node-network-form.ts +93 -99
  63. package/src/proxmox/node-network-read-failure.test.ts +206 -0
  64. package/src/proxmox/node-network-wire.ts +239 -0
  65. package/src/proxmox/node-network.ts +155 -167
@@ -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
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Pure functions over the notification policy tree — no SDK call, no Effect. Split out of
3
+ * `notification-policy.ts` the same way `dashboard-model.ts`/`alert-rule-group-model.ts` are split
4
+ * out of their resources.
5
+ *
6
+ * ★ THE TREE IS OPAQUE `Record<string, unknown>`, NOT THE SDK's OWN `Route` TYPE — same reasoning
7
+ * as `mute-timing.ts`'s `timeIntervals`: Grafana's `Matchers`/`ObjectMatchers`/`MatchRegexps`
8
+ * (matcher expression shapes) are complex nested union/array types this family does not attempt
9
+ * to model field-by-field. `routePutPolicyTree`'s request body encodes/decodes any key
10
+ * verbatim regardless (the same unmodeled-key passthrough `mute-timing.ts`'s file header
11
+ * documents for this SDK generally), so an opaque pass-through both round-trips correctly and
12
+ * stays usable for every matcher/route shape Grafana accepts, not just the ones this file would
13
+ * otherwise hand-model.
14
+ */
15
+ export type PolicyRoute = Record<string, unknown>;
16
+
17
+ /** Grafana-managed, never declared content. MEASURED against `Route` (services/grafana.ts):
18
+ * `provenance` is the only field on this type that is unambiguously read-only (every other field
19
+ * — `receiver`, `group_by`, `matchers`, `routes`, … — is exactly what a declaration sets). An
20
+ * object this family just wrote reads back with `provenance: "api"`, which the declaration never
21
+ * sets, so comparing it would show a false `update` forever — the same reasoning
22
+ * `alert-rule-group-model.ts`'s own `RULE_VOLATILE_FIELDS` note gives for a rule's `provenance`.
23
+ * Stripped RECURSIVELY: `Route.routes` is `Array<Route>`, so a nested route could in principle
24
+ * carry its own `provenance` too — not measured live (no live policy tree in this house to read),
25
+ * but stripping it everywhere is the conservative reading, the same one `provisioned.ts`'s
26
+ * `managedBy` note takes for an unmeasured value shape. */
27
+ const ROUTE_VOLATILE_FIELDS = ['provenance'] as const;
28
+
29
+ export const normalizeRoute = (route: PolicyRoute): PolicyRoute => {
30
+ const out: PolicyRoute = {};
31
+ for (const [key, value] of Object.entries(route)) {
32
+ if ((ROUTE_VOLATILE_FIELDS as readonly string[]).includes(key)) continue;
33
+ if (key === 'routes' && Array.isArray(value)) {
34
+ out.routes = value.map((child) => normalizeRoute(child as PolicyRoute));
35
+ continue;
36
+ }
37
+ out[key] = value;
38
+ }
39
+ return out;
40
+ };
41
+
42
+ export interface FlatRoute {
43
+ /** Dotted child-index path from the root, e.g. `root.0.1` — WHERE this occurrence sits, for the
44
+ * warning message only; never part of `key`. */
45
+ readonly path: string;
46
+ readonly receiver: string;
47
+ /**
48
+ * Identity for the dropped-route warning — MEASURED FIX (an adversarial review of this PR found
49
+ * the first version's identity, plain per-node JSON in a `Set`, collided across the WHOLE TREE:
50
+ * a duplicate route added anywhere, not just as a live-only sibling, silently matched an
51
+ * unrelated declared node with the same fields and the drop went unwarned). `key` is now the
52
+ * chain of ancestor own-fields from the root down to this node (this node's own fields included,
53
+ * `routes` children excluded at every level, `\0`-joined — a byte no route's own JSON can
54
+ * contain): two nodes are "the same" only if they sit under the identical lineage of ancestors,
55
+ * which makes an identical route under a DIFFERENT parent correctly distinct, while still being
56
+ * ORDER-INSENSITIVE among siblings (a shared parent contributes the same ancestor key to every
57
+ * child regardless of index) — a plain reorder is never reported as a drop.
58
+ * `notification-policy-drop-warning.ts` counts occurrences of each `key` as a MULTISET (declared
59
+ * count vs. live count), not set membership, so N live duplicates of the same node under the
60
+ * same parent are only "dropped" past however many the declaration itself repeats.
61
+ */
62
+ readonly key: string;
63
+ }
64
+
65
+ const ownFieldsJson = (route: PolicyRoute): string => {
66
+ const { routes: _routes, ...ownFields } = route;
67
+ return JSON.stringify(ownFields);
68
+ };
69
+
70
+ /**
71
+ * Depth-first flatten of a (already-normalized) route tree, `root` itself included at `path:
72
+ * 'root'` and `key` equal to its own fields alone (no ancestor to chain from). Used only by
73
+ * `notification-policy-drop-warning.ts`'s best-effort "what did this write remove" detection —
74
+ * `matches()` in `notification-policy.ts` never uses this, only `declaredContentMatches` on the
75
+ * tree as a whole, so nothing about drift DETECTION depends on it.
76
+ *
77
+ * ⚠️ STILL A BEST-EFFORT KEY, NOT A STABLE ONE — Grafana's policy routes have no id/uid the way
78
+ * every other object in this family does; `key` is a measured, deliberate improvement over plain
79
+ * per-node identity (see `FlatRoute.key`'s own doc), not a claim of a real Grafana-side key. A
80
+ * false negative here (missing a genuine drop) never hides real drift from `matches()`, which
81
+ * compares the whole tree structurally regardless of this file.
82
+ */
83
+ export const flattenRoutes = (
84
+ route: PolicyRoute,
85
+ path = 'root',
86
+ ancestorKey = '',
87
+ ): ReadonlyArray<FlatRoute> => {
88
+ const children = Array.isArray(route.routes) ? (route.routes as PolicyRoute[]) : [];
89
+ const ownJson = ownFieldsJson(route);
90
+ const key = ancestorKey === '' ? ownJson : `${ancestorKey}\0${ownJson}`;
91
+ const self: FlatRoute = {
92
+ key,
93
+ path,
94
+ receiver: typeof route.receiver === 'string' ? route.receiver : '(inherited)',
95
+ };
96
+ return [
97
+ self,
98
+ ...children.flatMap((child, index) => flattenRoutes(child, `${path}.${index}`, key)),
99
+ ];
100
+ };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `Grafana.NotificationPolicy`'s two refusal/gate mechanisms: the foreign-provenance refusal on
3
+ * `update` (explicit foreign, missing, and explicit-none-is-writable — the same fail-closed rule
4
+ * kit PR 250's adversarial review established) and the `allowReset` gate on `destroy` (never calls
5
+ * `routeResetPolicyTree` without it, regardless of Alchemy's own removal policy). Split out of
6
+ * `notification-policy.test.ts` to keep each file under the house's 250-line cap.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import * as Effect from 'effect/Effect';
10
+ import { GrafanaProvisionedObjectError } from './alerting-provenance.ts';
11
+ import { PATH, liveTree, props } from './notification-policy-fixtures.ts';
12
+ import { spec } from './notification-policy.ts';
13
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
14
+ import { grafanaOperations } from './resource.ts';
15
+
16
+ describe('foreign provenance refuses update', () => {
17
+ test('a file-provisioned tree refuses update and sends no PUT', async () => {
18
+ const fileProvisioned = { ...liveTree, provenance: 'file' };
19
+ const fake = fakeGrafana((method, url) =>
20
+ method === 'GET' && url.pathname === PATH
21
+ ? Response.json(fileProvisioned)
22
+ : fakeFailure(500, 'no write should be attempted'),
23
+ );
24
+ const failure = await Effect.runPromise(
25
+ Effect.flip(
26
+ grafanaOperations(spec)
27
+ .reconcile({ route: { ...props.route, receiver: 'other' } })
28
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
29
+ ),
30
+ );
31
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
32
+ expect(failure.message).toContain('file');
33
+ });
34
+
35
+ test('a response with no provenance field at all refuses update', async () => {
36
+ const noProvenance: Record<string, unknown> = { ...liveTree };
37
+ delete noProvenance.provenance;
38
+ const fake = fakeGrafana((method, url) =>
39
+ method === 'GET' && url.pathname === PATH
40
+ ? Response.json(noProvenance)
41
+ : fakeFailure(500, 'no write should be attempted'),
42
+ );
43
+ const failure = await Effect.runPromise(
44
+ Effect.flip(
45
+ grafanaOperations(spec)
46
+ .reconcile({ route: { ...props.route, receiver: 'other' } })
47
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
48
+ ),
49
+ );
50
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
51
+ expect(failure.message).toContain('did not report a provenance');
52
+ });
53
+
54
+ test('an EXPLICIT empty-string provenance (ProvenanceNone) is writable', async () => {
55
+ const explicitlyNone = { ...liveTree, provenance: '' };
56
+ const fake = fakeGrafana((method, url) => {
57
+ if (method === 'GET' && url.pathname === PATH) return Response.json(explicitlyNone);
58
+ if (method === 'PUT' && url.pathname === PATH) return Response.json(explicitlyNone);
59
+ return fakeFailure(500, 'unexpected request');
60
+ });
61
+ await Effect.runPromise(
62
+ grafanaOperations(spec)
63
+ .reconcile({ route: { ...props.route, receiver: 'other' } })
64
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
65
+ );
66
+ expect(fake.seen.some((s) => s.method === 'PUT')).toBe(true);
67
+ });
68
+ });
69
+
70
+ describe('destroy never resets without allowReset', () => {
71
+ test('destroy without allowReset sends no DELETE — retained, not reset', async () => {
72
+ const fake = fakeGrafana((method, url) =>
73
+ method === 'GET' && url.pathname === PATH
74
+ ? Response.json(liveTree)
75
+ : fakeFailure(500, 'no DELETE should be attempted'),
76
+ );
77
+ await Effect.runPromise(
78
+ grafanaOperations(spec)
79
+ .destroy(props)
80
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
81
+ );
82
+ expect(fake.seen).toEqual([{ method: 'GET', path: PATH }]);
83
+ });
84
+
85
+ test('destroy with allowReset: true calls routeResetPolicyTree', async () => {
86
+ const fake = fakeGrafana((method, url) => {
87
+ if (method === 'GET' && url.pathname === PATH) return Response.json(liveTree);
88
+ if (method === 'DELETE' && url.pathname === PATH) return Response.json({});
89
+ return fakeFailure(500, 'unexpected request');
90
+ });
91
+ await Effect.runPromise(
92
+ grafanaOperations(spec)
93
+ .destroy({ ...props, allowReset: true })
94
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
95
+ );
96
+ expect(fake.seen).toEqual([
97
+ { method: 'GET', path: PATH },
98
+ { method: 'DELETE', path: PATH },
99
+ ]);
100
+ });
101
+
102
+ test('a foreign-provenance tree refuses destroy even with allowReset: true', async () => {
103
+ const fileProvisioned = { ...liveTree, provenance: 'file' };
104
+ const fake = fakeGrafana((method, url) =>
105
+ method === 'GET' && url.pathname === PATH
106
+ ? Response.json(fileProvisioned)
107
+ : fakeFailure(500, 'no DELETE should be attempted'),
108
+ );
109
+ const failure = await Effect.runPromise(
110
+ Effect.flip(
111
+ grafanaOperations(spec)
112
+ .destroy({ ...props, allowReset: true })
113
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
114
+ ),
115
+ );
116
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
117
+ expect(fake.seen).toEqual([{ method: 'GET', path: PATH }]);
118
+ });
119
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `Grafana.NotificationPolicy` against a fake Grafana — the singleton read (never `undefined`),
3
+ * reconcile's adopt-and-update-only posture, and route-order significance. Provenance/reset-gate
4
+ * tests: `notification-policy-refusals.test.ts`. Drop-warning tests:
5
+ * `notification-policy-drop-warning.test.ts` — split to keep each file under the house's
6
+ * 250-line cap. Mirrors `alert-rule-group.test.ts`.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import * as Effect from 'effect/Effect';
10
+ import { PATH, declaredRoute, liveTree, props } from './notification-policy-fixtures.ts';
11
+ import { spec } from './notification-policy.ts';
12
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
13
+ import { grafanaOperations } from './resource.ts';
14
+
15
+ describe('spec.fetchLive', () => {
16
+ test('a live tree decodes and normalizes into typed attributes', async () => {
17
+ const fake = fakeGrafana((method, url) =>
18
+ method === 'GET' && url.pathname === PATH ? Response.json(liveTree) : fakeFailure(404, 'x'),
19
+ );
20
+ const live = await Effect.runPromise(
21
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
22
+ );
23
+ expect(live !== undefined && spec.attributes(live, props)).toEqual({
24
+ provenance: 'api',
25
+ route: declaredRoute,
26
+ });
27
+ expect(fake.bodies[0]).toBeUndefined();
28
+ });
29
+
30
+ test('a 403 propagates — never folded to absent, and there is nothing TO fold to (no NotFound case)', async () => {
31
+ const fake = fakeGrafana(() => fakeFailure(403, 'forbidden'));
32
+ const failure = await Effect.runPromise(
33
+ Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
34
+ );
35
+ expect(failure._tag).toBe('Forbidden');
36
+ });
37
+ });
38
+
39
+ describe('reconcile — adopt-and-update-only, never create', () => {
40
+ test('declaring exactly what is live is a noop: no write is ever sent', async () => {
41
+ const fake = fakeGrafana((method, url) =>
42
+ method === 'GET' && url.pathname === PATH
43
+ ? Response.json(liveTree)
44
+ : fakeFailure(500, 'reconcile should not write when nothing changed'),
45
+ );
46
+ await Effect.runPromise(
47
+ grafanaOperations(spec)
48
+ .reconcile(props)
49
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
50
+ );
51
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
52
+ });
53
+
54
+ test('a content change PUTs the declared tree, never a create call', async () => {
55
+ const changed = { ...declaredRoute, receiver: 'other-receiver' };
56
+ const fake = fakeGrafana((method, url) => {
57
+ if (method === 'GET' && url.pathname === PATH) return Response.json(liveTree);
58
+ if (method === 'PUT' && url.pathname === PATH) {
59
+ return Response.json({ ...liveTree, receiver: 'other-receiver' });
60
+ }
61
+ return fakeFailure(500, 'unexpected request');
62
+ });
63
+ await Effect.runPromise(
64
+ grafanaOperations(spec)
65
+ .reconcile({ route: changed })
66
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
67
+ );
68
+ const put = fake.seen.findIndex((s) => s.method === 'PUT');
69
+ expect(put).toBeGreaterThanOrEqual(0);
70
+ expect(fake.bodies[put]).toMatchObject({ receiver: 'other-receiver' });
71
+ });
72
+ });
73
+
74
+ describe('route order significant', () => {
75
+ test('the same two routes reversed shows a mismatch', async () => {
76
+ const fake = fakeGrafana((method, url) =>
77
+ method === 'GET' && url.pathname === PATH ? Response.json(liveTree) : fakeFailure(500, 'x'),
78
+ );
79
+ const live = await Effect.runPromise(
80
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
81
+ );
82
+ const attrs = live !== undefined ? spec.attributes(live, props) : undefined;
83
+ const reversed = { route: { ...declaredRoute, routes: [...declaredRoute.routes].reverse() } };
84
+ expect(attrs !== undefined && spec.matches(attrs, reversed)).toBe(false);
85
+ });
86
+ });