@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,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
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.NotificationPolicy` — the notification policy tree. A SINGLETON: `GET`/`PUT`
|
|
3
|
+
* `/v1/provisioning/policies` always address the ONE tree an instance has, there is no path
|
|
4
|
+
* parameter, and `RouteGetPolicyTreeError` is `Forbidden | GrafanaOpError` — no `NotFound` case at
|
|
5
|
+
* all, MEASURED. `fetchLive` therefore NEVER returns `undefined`: this resource has no create and
|
|
6
|
+
* no delete in the sense every other resource in this family does — `reconcile`'s `before ===
|
|
7
|
+
* undefined` branch (`resource.ts`) is dead code here, and `create` below exists only because
|
|
8
|
+
* `GrafanaSpec.create` is a required field on the shared type, not because it is ever reached.
|
|
9
|
+
* "Declaring" this resource means ADOPTING whatever tree is live and then keeping it in sync —
|
|
10
|
+
* never creating or destroying the tree itself, only ever `PUT`-replacing its contents.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ `routeResetPolicyTree` IS NEVER CALLED UNLESS `allowReset: true` IS EXPLICITLY DECLARED — a
|
|
13
|
+
* second, independent gate on top of `defaultRemovalPolicy: 'retain'` below. Alchemy's own
|
|
14
|
+
* removal-policy gate decides whether the engine calls this resource's `delete` handler AT ALL
|
|
15
|
+
* (retain means it never does, unless a stack opts in with `.pipe(RemovalPolicy.destroy())` —
|
|
16
|
+
* the convention every `Bao.*`/`Grafana.Folder`/`Grafana.AlertRuleGroup` family member already
|
|
17
|
+
* uses); `allowReset` is a SEPARATE prop-level gate inside `destroy` itself, so even a stack that
|
|
18
|
+
* opted into destroy at the Alchemy level still needs this resource's own explicit opt-in before
|
|
19
|
+
* it will reset. Two gates, not one, because `routeResetPolicyTree` is uniquely destructive among
|
|
20
|
+
* everything this family calls: it wipes every declared route, receiver reference and matcher
|
|
21
|
+
* back to Grafana's own bare default in one call, for the WHOLE instance, not one object. Without
|
|
22
|
+
* `allowReset`, `destroy` logs a warning and returns — retained, not reset.
|
|
23
|
+
*
|
|
24
|
+
* ★ PUT REPLACES THE WHOLE TREE — A DROPPED SUB-ROUTE IS WARNED ABOUT, NEVER SILENT. Same shape as
|
|
25
|
+
* kit PR 254's `Grafana.AlertRuleGroup` fix (an adversarial review of that PR found the identical
|
|
26
|
+
* risk for rule groups): `notification-policy-drop-warning.ts`'s `warnOnDroppedRoutes`, called
|
|
27
|
+
* from both this resource's custom `diff` (so `bun run plan` shows it before anything is
|
|
28
|
+
* written) and from `update` itself.
|
|
29
|
+
*
|
|
30
|
+
* ⛔ ROUTE ORDER IS SIGNIFICANT — NEVER REORDERED. Same as `Grafana.AlertRuleGroup`'s rules:
|
|
31
|
+
* `subset-match.ts`'s array comparison is position-significant, and Alertmanager itself routes
|
|
32
|
+
* an alert to the FIRST matching route in array order — reordering routes is a genuine routing
|
|
33
|
+
* change, not cosmetic.
|
|
34
|
+
*
|
|
35
|
+
* ⛔ A FOREIGN-PROVENANCE TREE REFUSES EVERY UPDATE — `alerting-provenance.ts`, unchanged from kit
|
|
36
|
+
* PR 250/254 (including the missing-means-foreign fix: `Route.provenance` is optional on the
|
|
37
|
+
* wire, and `undefined` is never folded into `""`). `create` is never at risk — see above, it is
|
|
38
|
+
* unreachable.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ GRAFANA-INJECTED DEFAULTS ARE TOLERATED THE `declaredContentMatches` WAY, NOT HAND-NORMALIZED
|
|
41
|
+
* — `matches()` below runs the (volatile-field-stripped) declared and live trees through
|
|
42
|
+
* `subset-match.ts`'s tolerant-subset comparison, the same mechanism `Grafana.Dashboard`'s
|
|
43
|
+
* panel-`datasource` auto-decoration and `Grafana.ContactPoint`'s settings defaults already rely
|
|
44
|
+
* on: a live tree may carry additional fields, at any depth, the declaration never mentioned
|
|
45
|
+
* (Grafana injecting a default `group_wait`/`group_interval`/`repeat_interval`/`group_by: []`
|
|
46
|
+
* when the declaration omits them is the plausible case) without failing the match. ⚠️ NOT
|
|
47
|
+
* MEASURED against a live instance — no live policy tree exists in this house to read (no live
|
|
48
|
+
* calls in this PR either) — this is the documented, conservative default every unmeasured value
|
|
49
|
+
* shape in this family takes, not a guess dressed up as a fact.
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ `X-Disable-Provenance` IS NEVER SET — same reasoning as `contact-point.ts`'s file header.
|
|
52
|
+
*/
|
|
53
|
+
import { isResolved } from 'alchemy/Diff';
|
|
54
|
+
import type { Input } from 'alchemy/Input';
|
|
55
|
+
import { Resource } from 'alchemy';
|
|
56
|
+
import * as Provider from 'alchemy/Provider';
|
|
57
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
58
|
+
import * as Effect from 'effect/Effect';
|
|
59
|
+
import {
|
|
60
|
+
type GrafanaProvisionedObjectError,
|
|
61
|
+
isForeignProvenance,
|
|
62
|
+
refuseIfForeignProvenance,
|
|
63
|
+
} from './alerting-provenance.ts';
|
|
64
|
+
import { type PolicyRoute, normalizeRoute } from './notification-policy-model.ts';
|
|
65
|
+
import { warnOnDroppedRoutes } from './notification-policy-drop-warning.ts';
|
|
66
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
67
|
+
import { declaredContentMatches } from './subset-match.ts';
|
|
68
|
+
|
|
69
|
+
export type { PolicyRoute };
|
|
70
|
+
|
|
71
|
+
export interface NotificationPolicyProps {
|
|
72
|
+
/** The root route and its full nested tree — Grafana's `Route` shape (`receiver`, `group_by`,
|
|
73
|
+
* `matchers`, nested `routes[]`, …), passed through opaquely — see the file header. Order of
|
|
74
|
+
* `routes[]` at every level is significant. */
|
|
75
|
+
route: PolicyRoute;
|
|
76
|
+
/** Lets `destroy` actually call `routeResetPolicyTree`. Defaults to `false` — see the file
|
|
77
|
+
* header for why this is a second gate on top of Alchemy's own removal policy. */
|
|
78
|
+
allowReset?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface NotificationPolicyAttributes {
|
|
82
|
+
route: PolicyRoute;
|
|
83
|
+
/** `""`/`"api"` (this family may write); anything else, INCLUDING `undefined`, refuses — see
|
|
84
|
+
* alerting-provenance.ts's "fails open" note. */
|
|
85
|
+
provenance: string | undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface GrafanaNotificationPolicy extends Resource<
|
|
89
|
+
'Grafana.NotificationPolicy',
|
|
90
|
+
NotificationPolicyProps,
|
|
91
|
+
NotificationPolicyAttributes,
|
|
92
|
+
never
|
|
93
|
+
> {}
|
|
94
|
+
|
|
95
|
+
export const GrafanaNotificationPolicy = Resource<GrafanaNotificationPolicy>(
|
|
96
|
+
'Grafana.NotificationPolicy',
|
|
97
|
+
{ defaultRemovalPolicy: 'retain' },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const body = (props: NotificationPolicyProps) =>
|
|
101
|
+
props.route as unknown as grafana.RoutePutPolicyTreeRequest;
|
|
102
|
+
|
|
103
|
+
export const spec: GrafanaSpec<
|
|
104
|
+
NotificationPolicyProps,
|
|
105
|
+
grafana.Route,
|
|
106
|
+
NotificationPolicyAttributes,
|
|
107
|
+
| grafana.RouteGetPolicyTreeError
|
|
108
|
+
| grafana.RoutePutPolicyTreeError
|
|
109
|
+
| grafana.RouteResetPolicyTreeError
|
|
110
|
+
| GrafanaProvisionedObjectError
|
|
111
|
+
> = {
|
|
112
|
+
attributes: (live) => ({
|
|
113
|
+
provenance: live.provenance,
|
|
114
|
+
route: normalizeRoute(live as unknown as PolicyRoute),
|
|
115
|
+
}),
|
|
116
|
+
// ⚠️ UNREACHABLE IN PRACTICE — see the file header. Implemented as the same PUT `update` sends,
|
|
117
|
+
// defensively, rather than `Effect.die`, in case a future Grafana version ever does answer
|
|
118
|
+
// `NotFound` here; `GrafanaSpec.create` is required by the shared type either way.
|
|
119
|
+
create: (props) => grafana.routePutPolicyTree(body(props)),
|
|
120
|
+
destroy: (props, live) =>
|
|
121
|
+
Effect.gen(function* () {
|
|
122
|
+
if (isForeignProvenance(live.provenance)) {
|
|
123
|
+
return yield* refuseIfForeignProvenance(
|
|
124
|
+
'Grafana.NotificationPolicy',
|
|
125
|
+
'default',
|
|
126
|
+
live.provenance,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (props.allowReset !== true) {
|
|
130
|
+
yield* Effect.logWarning(
|
|
131
|
+
'Grafana.NotificationPolicy: destroy requested but allowReset is not set -- the policy ' +
|
|
132
|
+
'tree is RETAINED, not reset. Grafana always needs a root route; resetting wipes ' +
|
|
133
|
+
"every declared route/receiver/matcher back to Grafana's own default for the WHOLE " +
|
|
134
|
+
'instance. Set allowReset: true to actually call routeResetPolicyTree.',
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
yield* grafana.routeResetPolicyTree({});
|
|
139
|
+
}),
|
|
140
|
+
fetchLive: (_props) => grafana.routeGetPolicyTree({}),
|
|
141
|
+
matches: (attributes, props) =>
|
|
142
|
+
declaredContentMatches(normalizeRoute(props.route), attributes.route),
|
|
143
|
+
update: (props, live) =>
|
|
144
|
+
Effect.gen(function* () {
|
|
145
|
+
if (isForeignProvenance(live.provenance)) {
|
|
146
|
+
return yield* refuseIfForeignProvenance(
|
|
147
|
+
'Grafana.NotificationPolicy',
|
|
148
|
+
'default',
|
|
149
|
+
live.provenance,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
yield* warnOnDroppedRoutes(props.route, live as unknown as PolicyRoute);
|
|
153
|
+
yield* grafana.routePutPolicyTree(body(props));
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* ⚠️ A LOCAL REIMPLEMENTATION OF `resource.ts`'s GENERIC `diff`, NOT A WRAP OF IT — same reasoning
|
|
159
|
+
* as `alert-rule-group.ts`'s own `diff`: the generic version has no seam to run the drop-warning
|
|
160
|
+
* side effect against the live tree it reads, and wrapping it would mean a second live fetch
|
|
161
|
+
* just to see what it already saw.
|
|
162
|
+
*/
|
|
163
|
+
const diff = ({
|
|
164
|
+
news,
|
|
165
|
+
output,
|
|
166
|
+
}: {
|
|
167
|
+
news: Input<NotificationPolicyProps>;
|
|
168
|
+
output: NotificationPolicyAttributes | undefined;
|
|
169
|
+
}) =>
|
|
170
|
+
Effect.gen(function* () {
|
|
171
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
172
|
+
const live = yield* spec.fetchLive(news);
|
|
173
|
+
// ⚠️ UNREACHABLE IN PRACTICE — see the file header: `routeGetPolicyTree` has no `NotFound`
|
|
174
|
+
// case, so `live` is never actually `undefined`. `GrafanaSpec.fetchLive`'s shared return
|
|
175
|
+
// type is `Live | undefined` for every resource in the family, so this check exists purely
|
|
176
|
+
// to satisfy that type, the same defensive stance `create` above takes.
|
|
177
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
178
|
+
yield* warnOnDroppedRoutes(news.route, live as unknown as PolicyRoute);
|
|
179
|
+
const mapped = spec.attributes(live, news);
|
|
180
|
+
return mapped !== undefined && spec.matches(mapped, news)
|
|
181
|
+
? ({ action: 'noop' } as const)
|
|
182
|
+
: ({ action: 'update' } as const);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
export const handlers = { ...grafanaHandlers(spec), diff };
|
|
186
|
+
|
|
187
|
+
export const GrafanaNotificationPolicyProvider = () =>
|
|
188
|
+
Provider.effect(
|
|
189
|
+
GrafanaNotificationPolicy,
|
|
190
|
+
Effect.succeed(GrafanaNotificationPolicy.Provider.of(handlers)),
|
|
191
|
+
);
|
package/src/grafana/providers.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { GrafanaDatasourceProvider } from './datasource.ts';
|
|
|
20
20
|
import { GrafanaFolderProvider } from './folder.ts';
|
|
21
21
|
import { GrafanaMessageTemplateProvider } from './message-template.ts';
|
|
22
22
|
import { GrafanaMuteTimingProvider } from './mute-timing.ts';
|
|
23
|
+
import { GrafanaNotificationPolicyProvider } from './notification-policy.ts';
|
|
23
24
|
|
|
24
25
|
export const grafanaProviders = (target: GrafanaTarget) =>
|
|
25
26
|
Layer.mergeAll(
|
|
@@ -30,4 +31,5 @@ export const grafanaProviders = (target: GrafanaTarget) =>
|
|
|
30
31
|
GrafanaMuteTimingProvider(),
|
|
31
32
|
GrafanaMessageTemplateProvider(),
|
|
32
33
|
GrafanaAlertRuleGroupProvider(),
|
|
34
|
+
GrafanaNotificationPolicyProvider(),
|
|
33
35
|
).pipe(Layer.provide(grafanaCredentials(target)));
|