@homeflare/alchemy 0.35.0 → 0.37.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 (127) hide show
  1. package/README.md +14 -0
  2. package/dist/argocd/cluster.d.ts +102 -0
  3. package/dist/argocd/cluster.d.ts.map +1 -0
  4. package/dist/argocd/credentials.d.ts +37 -0
  5. package/dist/argocd/credentials.d.ts.map +1 -0
  6. package/dist/argocd/fake-argocd.d.ts +42 -0
  7. package/dist/argocd/fake-argocd.d.ts.map +1 -0
  8. package/dist/argocd/git-credentials.d.ts +29 -0
  9. package/dist/argocd/git-credentials.d.ts.map +1 -0
  10. package/dist/argocd/index.d.ts +19 -0
  11. package/dist/argocd/index.d.ts.map +1 -0
  12. package/dist/argocd/index.js +306 -0
  13. package/dist/argocd/index.js.map +17 -0
  14. package/dist/argocd/providers.d.ts +13 -0
  15. package/dist/argocd/providers.d.ts.map +1 -0
  16. package/dist/argocd/repo-creds.d.ts +67 -0
  17. package/dist/argocd/repo-creds.d.ts.map +1 -0
  18. package/dist/argocd/repository.d.ts +87 -0
  19. package/dist/argocd/repository.d.ts.map +1 -0
  20. package/dist/argocd/resource.d.ts +90 -0
  21. package/dist/argocd/resource.d.ts.map +1 -0
  22. package/dist/cloudflare/index.js +4 -4
  23. package/dist/cloudflare/index.js.map +3 -3
  24. package/dist/cloudflare/r2-bucket-lock.d.ts +14 -5
  25. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
  26. package/dist/grafana/index.d.ts +5 -4
  27. package/dist/grafana/index.d.ts.map +1 -1
  28. package/dist/grafana/index.js +204 -90
  29. package/dist/grafana/index.js.map +7 -4
  30. package/dist/grafana/notification-policy-drop-warning.d.ts +27 -0
  31. package/dist/grafana/notification-policy-drop-warning.d.ts.map +1 -0
  32. package/dist/grafana/notification-policy-fixtures.d.ts +34 -0
  33. package/dist/grafana/notification-policy-fixtures.d.ts.map +1 -0
  34. package/dist/grafana/notification-policy-model.d.ts +53 -0
  35. package/dist/grafana/notification-policy-model.d.ts.map +1 -0
  36. package/dist/grafana/notification-policy.d.ts +57 -0
  37. package/dist/grafana/notification-policy.d.ts.map +1 -0
  38. package/dist/grafana/providers.d.ts +1 -1
  39. package/dist/grafana/providers.d.ts.map +1 -1
  40. package/dist/litellm/index.js.map +2 -2
  41. package/dist/litellm/operations.d.ts.map +1 -1
  42. package/dist/proxmox/ceph-daemon-form.d.ts +4 -5
  43. package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
  44. package/dist/proxmox/ceph-daemon-wire.d.ts +33 -0
  45. package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
  46. package/dist/proxmox/ceph-daemon.d.ts +35 -0
  47. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  48. package/dist/proxmox/ceph-fs-distilled.d.ts +14 -0
  49. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -0
  50. package/dist/proxmox/ceph-fs-wire.d.ts +20 -11
  51. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  52. package/dist/proxmox/ceph-fs.d.ts +31 -6
  53. package/dist/proxmox/ceph-fs.d.ts.map +1 -1
  54. package/dist/proxmox/ceph-osd-tree.d.ts +12 -3
  55. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  56. package/dist/proxmox/ceph-osd-write.d.ts +17 -2
  57. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  58. package/dist/proxmox/ceph-osd.d.ts +6 -0
  59. package/dist/proxmox/ceph-osd.d.ts.map +1 -1
  60. package/dist/proxmox/ceph-pool-settle.d.ts +10 -4
  61. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  62. package/dist/proxmox/ceph-pool-wire.d.ts +46 -0
  63. package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -0
  64. package/dist/proxmox/ceph-pool.d.ts +10 -5
  65. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  66. package/dist/proxmox/group-wire.d.ts +1 -1
  67. package/dist/proxmox/group-wire.d.ts.map +1 -1
  68. package/dist/proxmox/index.js +611 -477
  69. package/dist/proxmox/index.js.map +15 -12
  70. package/dist/proxmox/storage-wire.d.ts +8 -1
  71. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  72. package/dist/proxmox/user-wire.d.ts +1 -1
  73. package/dist/proxmox/user-wire.d.ts.map +1 -1
  74. package/docs/argocd-kubernetes.md +100 -0
  75. package/docs/argocd.md +139 -0
  76. package/docs/credentials.md +9 -0
  77. package/docs/grafana-alerting.md +3 -2
  78. package/docs/grafana-notification-policy.md +127 -0
  79. package/docs/grafana.md +12 -8
  80. package/docs/litellm.md +17 -0
  81. package/docs/peers.md +7 -0
  82. package/docs/unifi.md +45 -0
  83. package/docs/upstream-conformance.md +19 -5
  84. package/package.json +8 -3
  85. package/src/argocd/cluster.test.ts +120 -0
  86. package/src/argocd/cluster.ts +222 -0
  87. package/src/argocd/credentials.ts +38 -0
  88. package/src/argocd/fake-argocd.ts +60 -0
  89. package/src/argocd/git-credentials.ts +71 -0
  90. package/src/argocd/index.ts +40 -0
  91. package/src/argocd/providers.ts +17 -0
  92. package/src/argocd/repo-creds.test.ts +123 -0
  93. package/src/argocd/repo-creds.ts +115 -0
  94. package/src/argocd/repository.test.ts +167 -0
  95. package/src/argocd/repository.ts +165 -0
  96. package/src/argocd/resource.ts +150 -0
  97. package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
  98. package/src/cloudflare/r2-bucket-lock.ts +25 -18
  99. package/src/grafana/index.ts +11 -4
  100. package/src/grafana/notification-policy-drop-warning.test.ts +163 -0
  101. package/src/grafana/notification-policy-drop-warning.ts +60 -0
  102. package/src/grafana/notification-policy-fixtures.ts +27 -0
  103. package/src/grafana/notification-policy-model.test.ts +104 -0
  104. package/src/grafana/notification-policy-model.ts +100 -0
  105. package/src/grafana/notification-policy-refusals.test.ts +119 -0
  106. package/src/grafana/notification-policy.test.ts +86 -0
  107. package/src/grafana/notification-policy.ts +191 -0
  108. package/src/grafana/providers.ts +2 -0
  109. package/src/litellm/operations.ts +8 -4
  110. package/src/proxmox/ceph-daemon-form.ts +7 -8
  111. package/src/proxmox/ceph-daemon-wire.ts +115 -0
  112. package/src/proxmox/ceph-daemon-write.test.ts +158 -0
  113. package/src/proxmox/ceph-daemon.ts +65 -47
  114. package/src/proxmox/ceph-fs-delete.test.ts +81 -0
  115. package/src/proxmox/ceph-fs-distilled.ts +102 -0
  116. package/src/proxmox/ceph-fs-wire.ts +17 -31
  117. package/src/proxmox/ceph-fs-write.test.ts +67 -0
  118. package/src/proxmox/ceph-fs.ts +66 -69
  119. package/src/proxmox/ceph-osd-tree.ts +14 -5
  120. package/src/proxmox/ceph-osd-write.test.ts +61 -0
  121. package/src/proxmox/ceph-osd-write.ts +33 -6
  122. package/src/proxmox/ceph-osd.ts +6 -0
  123. package/src/proxmox/ceph-pool-settle.ts +13 -7
  124. package/src/proxmox/ceph-pool-wire.ts +110 -0
  125. package/src/proxmox/ceph-pool-write.test.ts +145 -0
  126. package/src/proxmox/ceph-pool.ts +87 -98
  127. package/src/proxmox/distilled-task-await.test.ts +0 -124
@@ -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
+ });
@@ -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
+ );
@@ -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)));
@@ -58,10 +58,14 @@ const semaphoreFor = (baseUrl: string): Effect.Effect<Semaphore.Semaphore> =>
58
58
  /**
59
59
  * Serialises a write against this call's own base URL — see the file header.
60
60
  *
61
- * Resolving `Credentials` can itself fail typed (`ConfigError` — S20/S24, credentials.ts), so
62
- * the return type carries that alongside the wrapped operation's own error `E`; every call
63
- * site's declared error type already includes `ConfigError` as a member of the SDK's shared
64
- * `LitellmOpError` (protocol.ts), so this widening is a no-op for callers.
61
+ * ⚠️ `Credentials` itself ends in `Effect.orDie` on a missing/misspelled env var (Q6, decision
62
+ * 49 "upstream wins", 2026-09-24, matching distilled's own convention — 73 of 80 packages'
63
+ * `credentials.ts` do the same at `homeflare/base`), so resolving it here can no longer fail
64
+ * typed. The `| ConfigError` this function still adds to its return type stays only because
65
+ * `@distilled.cloud/litellm`'s own `LitellmOpError` (protocol.ts) unconditionally declares
66
+ * `ConfigError` as a member of every generated operation's error union regardless of
67
+ * `Credentials`'s own type — so `E` (the wrapped operation's error) already contains it, and
68
+ * this widening is a genuine no-op for callers, not dead type-state left over from the revert.
65
69
  */
66
70
  const mutate = <A, E>(
67
71
  io: Effect.Effect<A, E, LitellmOpContext>,