@homeflare/alchemy 0.32.0 → 0.34.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 (83) hide show
  1. package/dist/grafana/alerting-provenance.d.ts +80 -0
  2. package/dist/grafana/alerting-provenance.d.ts.map +1 -0
  3. package/dist/grafana/contact-point-fixtures.d.ts +28 -0
  4. package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
  5. package/dist/grafana/contact-point.d.ts +99 -0
  6. package/dist/grafana/contact-point.d.ts.map +1 -0
  7. package/dist/grafana/dashboard-model.d.ts +47 -0
  8. package/dist/grafana/dashboard-model.d.ts.map +1 -0
  9. package/dist/grafana/dashboard.d.ts +98 -0
  10. package/dist/grafana/dashboard.d.ts.map +1 -0
  11. package/dist/grafana/datasource.d.ts +2 -9
  12. package/dist/grafana/datasource.d.ts.map +1 -1
  13. package/dist/grafana/folder.d.ts +100 -0
  14. package/dist/grafana/folder.d.ts.map +1 -0
  15. package/dist/grafana/index.d.ts +16 -9
  16. package/dist/grafana/index.d.ts.map +1 -1
  17. package/dist/grafana/index.js +325 -16
  18. package/dist/grafana/index.js.map +15 -5
  19. package/dist/grafana/message-template.d.ts +70 -0
  20. package/dist/grafana/message-template.d.ts.map +1 -0
  21. package/dist/grafana/mute-timing.d.ts +112 -0
  22. package/dist/grafana/mute-timing.d.ts.map +1 -0
  23. package/dist/grafana/providers.d.ts +1 -1
  24. package/dist/grafana/providers.d.ts.map +1 -1
  25. package/dist/grafana/provisioned.d.ts +11 -0
  26. package/dist/grafana/provisioned.d.ts.map +1 -0
  27. package/dist/grafana/secret-refs.d.ts +19 -0
  28. package/dist/grafana/secret-refs.d.ts.map +1 -0
  29. package/dist/grafana/subset-match.d.ts +2 -0
  30. package/dist/grafana/subset-match.d.ts.map +1 -0
  31. package/dist/linux/index.d.ts +1 -1
  32. package/dist/linux/index.d.ts.map +1 -1
  33. package/dist/linux/index.js +3 -1
  34. package/dist/linux/index.js.map +1 -1
  35. package/dist/proxmox/index.js +76 -45
  36. package/dist/proxmox/index.js.map +6 -6
  37. package/dist/proxmox/zfs-pool-form.d.ts +124 -0
  38. package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
  39. package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
  40. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
  41. package/dist/proxmox/zfs-pool.d.ts +27 -52
  42. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  43. package/docs/grafana-alerting.md +145 -0
  44. package/docs/grafana-folder-dashboard.md +145 -0
  45. package/docs/grafana.md +12 -10
  46. package/docs/upstream-conformance.md +31 -4
  47. package/package.json +1 -1
  48. package/src/grafana/alerting-provenance.ts +109 -0
  49. package/src/grafana/contact-point-fixtures.ts +25 -0
  50. package/src/grafana/contact-point-provenance.test.ts +113 -0
  51. package/src/grafana/contact-point-secrets.test.ts +117 -0
  52. package/src/grafana/contact-point.test.ts +123 -0
  53. package/src/grafana/contact-point.ts +179 -0
  54. package/src/grafana/dashboard-model.test.ts +111 -0
  55. package/src/grafana/dashboard-model.ts +62 -0
  56. package/src/grafana/dashboard.test.ts +232 -0
  57. package/src/grafana/dashboard.ts +161 -0
  58. package/src/grafana/datasource.ts +5 -27
  59. package/src/grafana/folder.test.ts +212 -0
  60. package/src/grafana/folder.ts +161 -0
  61. package/src/grafana/index.ts +42 -9
  62. package/src/grafana/message-template.test.ts +212 -0
  63. package/src/grafana/message-template.ts +114 -0
  64. package/src/grafana/mute-timing.test.ts +227 -0
  65. package/src/grafana/mute-timing.ts +160 -0
  66. package/src/grafana/providers.ts +13 -1
  67. package/src/grafana/provisioned.ts +49 -0
  68. package/src/grafana/secret-refs.ts +48 -0
  69. package/src/grafana/subset-match.ts +71 -0
  70. package/src/linux/index-export.test.ts +25 -0
  71. package/src/linux/index.ts +15 -1
  72. package/src/proxmox/constraints-host-forms.test.ts +1 -1
  73. package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
  74. package/src/proxmox/zfs-pool-form.ts +236 -0
  75. package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
  76. package/src/proxmox/zfs-pool-wire.ts +132 -0
  77. package/src/proxmox/zfs-pool.ts +106 -76
  78. package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
  79. package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
  80. package/dist/proxmox/zfs-pool-write.d.ts +0 -128
  81. package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
  82. package/src/proxmox/zfs-pool-spec.ts +0 -76
  83. package/src/proxmox/zfs-pool-write.ts +0 -196
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `Grafana.Folder` — one Grafana folder, keyed by `uid`. Mirrors `datasource.ts`'s shape and
3
+ * doctrine (uid required, not generated — same reasoning, `getFolderByUID`/`updateFolder`/
4
+ * `deleteFolder` are all UID-keyed and `getFolders` (list) has no per-uid filter).
5
+ *
6
+ * ⛔ `description` IS WRITE-ONLY — MEASURED against `@distilled.cloud/grafana`'s generated types
7
+ * (`Folder` in services/grafana.ts has no `description` field at all, though
8
+ * `CreateFolderRequest`/`UpdateFolderRequest` both accept one). Grafana's classic folder GET
9
+ * never returns it, not even as an is-it-set flag the way `DataSource.secureJsonFields` reports
10
+ * for a datasource's secure fields. So `description` is sent on every create/update that
11
+ * declares it but is NEVER part of `matches` — this resource cannot detect drift in a value it
12
+ * is never shown, the same treatment `datasource.ts` gives `secureJsonDataRefs`.
13
+ *
14
+ * ⚠️ NESTING (`parentUid`) IS CREATE-ONLY — MEASURED against the same generated types.
15
+ * `CreateFolderRequest.parentUid` exists; `UpdateFolderRequest` has no `parentUid` field at all
16
+ * (`folder_uid`, `description`, `overwrite`, `title`, `version` — reparenting is simply not a
17
+ * thing `PUT /folders/{uid}` can do). A declaration whose `parentUid` no longer matches the live
18
+ * folder therefore REFUSES with `GrafanaFolderReparentError` rather than either silently doing
19
+ * nothing (which would lie about the declaration taking effect) or deleting and recreating the
20
+ * folder under the new parent (which — see the ⛔ below — takes every dashboard and alert rule in
21
+ * it with it). Move a folder by hand in Grafana, then adjust the declaration to match.
22
+ *
23
+ * ⛔ DELETE DEFAULTS TO `retain` — DELETING A FOLDER DELETES ITS DASHBOARDS AND ALERT RULES IN
24
+ * GRAFANA, per `deleteFolder`'s own operation description ("along with all dashboards (and their
25
+ * alerts) stored in the folder... cannot be reverted... also deletes all the subfolders"). That
26
+ * is the same class of multi-object cascade `openbao/mount.ts` guards with
27
+ * `defaultRemovalPolicy: 'retain'` ("disabling a mount destroys every secret under it") — VERIFIED
28
+ * there against Alchemy's own `Apply.ts` (not re-verified here; same engine, same option). A
29
+ * stack that explicitly wants the cascade opts in with `.pipe(RemovalPolicy.destroy())` on the
30
+ * declared instance; `destroy` below still implements the real DELETE in full either way (S11).
31
+ * `Grafana.Dashboard` does NOT default to retain — deleting one dashboard is the same
32
+ * single-object blast radius `Grafana.Datasource` already accepts without a retain guard, not
33
+ * this family's multi-object cascade.
34
+ *
35
+ * ⛔ A PROVISIONED FOLDER (`managedBy` set — see provisioned.ts) REFUSES EVERY UPDATE AND DESTROY.
36
+ * `matches` still compares structurally either way, so a mismatched declaration against a
37
+ * provisioned folder shows `update` in a plan (never a silent, permanently-hidden noop) and then
38
+ * fails loudly, by design, when reconcile actually tries to write.
39
+ */
40
+ import { Resource } from 'alchemy';
41
+ import * as Provider from 'alchemy/Provider';
42
+ import * as grafana from '@distilled.cloud/grafana';
43
+ import * as Data from 'effect/Data';
44
+ import * as Effect from 'effect/Effect';
45
+ import { type GrafanaProvisionedObjectError, refuseIfProvisioned } from './provisioned.ts';
46
+ import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
47
+
48
+ export interface FolderProps {
49
+ uid: string;
50
+ title: string;
51
+ /** Write-only — see the file header. Sent on every create/update that declares it; never diffed. */
52
+ description?: string;
53
+ /** Parent folder UID, for nested folders. Create-only — see the file header. */
54
+ parentUid?: string;
55
+ }
56
+
57
+ export interface FolderAttributes {
58
+ uid: string;
59
+ id: number;
60
+ title: string;
61
+ parentUid: string;
62
+ /** Non-empty when Grafana reports this folder as externally managed (e.g. provisioning). */
63
+ managedBy: string;
64
+ url: string;
65
+ version: number;
66
+ }
67
+
68
+ export interface GrafanaFolder extends Resource<
69
+ 'Grafana.Folder',
70
+ FolderProps,
71
+ FolderAttributes,
72
+ never
73
+ > {}
74
+
75
+ export const GrafanaFolder = Resource<GrafanaFolder>('Grafana.Folder', {
76
+ defaultRemovalPolicy: 'retain',
77
+ });
78
+
79
+ /** A declared `parentUid` disagrees with the live folder's — `updateFolder` cannot reparent. */
80
+ export class GrafanaFolderReparentError extends Data.TaggedError('GrafanaFolderReparentError')<{
81
+ readonly message: string;
82
+ }> {}
83
+
84
+ export const spec: GrafanaSpec<
85
+ FolderProps,
86
+ grafana.Folder,
87
+ FolderAttributes,
88
+ | grafana.CreateFolderError
89
+ | grafana.GetFolderByUIDError
90
+ | grafana.UpdateFolderError
91
+ | grafana.DeleteFolderError
92
+ | GrafanaProvisionedObjectError
93
+ | GrafanaFolderReparentError
94
+ > = {
95
+ attributes: (live) => ({
96
+ id: live.id ?? 0,
97
+ managedBy: live.managedBy ?? '',
98
+ parentUid: live.parentUid ?? '',
99
+ title: live.title ?? '',
100
+ uid: live.uid ?? '',
101
+ url: live.url ?? '',
102
+ version: live.version ?? 0,
103
+ }),
104
+ create: (props) =>
105
+ grafana.createFolder({
106
+ uid: props.uid,
107
+ title: props.title,
108
+ ...(props.description === undefined ? {} : { description: props.description }),
109
+ ...(props.parentUid === undefined ? {} : { parentUid: props.parentUid }),
110
+ }),
111
+ destroy: (props, live) =>
112
+ Effect.gen(function* () {
113
+ if (live.managedBy) {
114
+ return yield* refuseIfProvisioned('Grafana.Folder', props.uid, live.managedBy);
115
+ }
116
+ yield* grafana.deleteFolder({ folder_uid: props.uid });
117
+ }),
118
+ fetchLive: (props) =>
119
+ grafana
120
+ .getFolderByUID({ folder_uid: props.uid })
121
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
122
+ matches: (attributes, props) =>
123
+ attributes.title === props.title &&
124
+ (props.parentUid === undefined || attributes.parentUid === props.parentUid),
125
+ update: (props, live) =>
126
+ Effect.gen(function* () {
127
+ if (live.managedBy) {
128
+ return yield* refuseIfProvisioned('Grafana.Folder', props.uid, live.managedBy);
129
+ }
130
+ if (props.parentUid !== undefined && props.parentUid !== (live.parentUid ?? undefined)) {
131
+ return yield* Effect.fail(
132
+ new GrafanaFolderReparentError({
133
+ message:
134
+ `Grafana.Folder '${props.uid}' declares parentUid '${props.parentUid}' but is ` +
135
+ `live under '${live.parentUid ?? '(root)'}'. PUT /folders/{uid} cannot reparent a ` +
136
+ `folder — move it by hand in Grafana, then update the declaration to match.`,
137
+ }),
138
+ );
139
+ }
140
+ // ⚠️ UNLIKE DASHBOARD.TS's 412, THIS HAS NO VERIFIED CONFLICT SIGNAL. `version` is sent
141
+ // optimistically, but the SDK's own doc comment on `UpdateFolderRequest.version` says
142
+ // "only used by the legacy folder implementation" — on Grafana's newer unified-storage
143
+ // folder backend it is plausibly ignored outright, meaning two concurrent title/description
144
+ // writes could silently last-write-win with no error, unlike a dashboard save. NOT
145
+ // MEASURED against a live instance either way (no live calls in this PR; flagged by an
146
+ // adversarial review) — sending it is harmless (a no-op field on a backend that ignores it)
147
+ // and correct on one that still honors it, so it stays, but do not read its PRESENCE here
148
+ // as proof this resource detects folder update conflicts the way dashboard.ts does.
149
+ return yield* grafana.updateFolder({
150
+ folder_uid: props.uid,
151
+ title: props.title,
152
+ ...(props.description === undefined ? {} : { description: props.description }),
153
+ ...(live.version === undefined ? {} : { version: live.version }),
154
+ });
155
+ }),
156
+ };
157
+
158
+ export const handlers = grafanaHandlers(spec);
159
+
160
+ export const GrafanaFolderProvider = () =>
161
+ Provider.effect(GrafanaFolder, Effect.succeed(GrafanaFolder.Provider.of(handlers)));
@@ -2,22 +2,55 @@
2
2
  * Grafana providers for Alchemy.
3
3
  *
4
4
  * ⛔ THIS BARREL IS THE PUBLIC API, AND IT IS DELIBERATELY SMALLER THAN THE DIRECTORY. These are
5
- * the symbols a real stack consumes; the rest of the files (resource.ts's engine, fake-grafana.ts)
6
- * are internals a provider needs but a consumer should not depend on.
5
+ * the symbols a real stack consumes; the rest of the files (resource.ts's engine, provisioned.ts,
6
+ * dashboard-model.ts, fake-grafana.ts) are internals a provider needs but a consumer should not
7
+ * depend on.
7
8
  *
8
- * ⛔ ONLY `Grafana.Datasource` SHIPS HERE. `@distilled.cloud/grafana` (aliased onto
9
- * `@homeflare/distilled-grafana` — see docs/distilled-interim.md) now HAS folder, dashboard and
10
- * alerting-provisioning CRUD operations — the SDK-level gap docs/grafana.md and
11
- * docs/upstream-conformance.md recorded is fixed. Building `Grafana.Folder`, `Grafana.Dashboard`,
12
- * `Grafana.AlertRule` etc. on top of them is follow-up work this PR deliberately does not do —
13
- * see docs/grafana.md for what changed and what is still open.
9
+ * `Grafana.Datasource`, `Grafana.Folder`, `Grafana.Dashboard`, `Grafana.ContactPoint`,
10
+ * `Grafana.MuteTiming` and `Grafana.MessageTemplate` ship here. `Grafana.AlertRuleGroup` and
11
+ * `Grafana.NotificationPolicy` are stacked follow-up PRs on the same SDK operations — see
12
+ * docs/grafana.md, docs/grafana-folder-dashboard.md and docs/grafana-alerting.md for what shipped
13
+ * and what is still open.
14
14
  */
15
15
  export {
16
16
  type DatasourceAttributes,
17
17
  type DatasourceProps,
18
18
  GrafanaDatasource,
19
19
  GrafanaDatasourceProvider,
20
- GrafanaSecretRefUnsetError,
21
20
  } from './datasource.ts';
21
+ export {
22
+ type DashboardAttributes,
23
+ type DashboardModel,
24
+ type DashboardProps,
25
+ GrafanaDashboard,
26
+ GrafanaDashboardProvider,
27
+ } from './dashboard.ts';
28
+ export {
29
+ type FolderAttributes,
30
+ type FolderProps,
31
+ GrafanaFolder,
32
+ GrafanaFolderProvider,
33
+ GrafanaFolderReparentError,
34
+ } from './folder.ts';
35
+ export {
36
+ type ContactPointAttributes,
37
+ type ContactPointProps,
38
+ GrafanaContactPoint,
39
+ GrafanaContactPointProvider,
40
+ } from './contact-point.ts';
41
+ export {
42
+ type MessageTemplateAttributes,
43
+ type MessageTemplateProps,
44
+ GrafanaMessageTemplate,
45
+ GrafanaMessageTemplateProvider,
46
+ } from './message-template.ts';
47
+ export {
48
+ type MuteTimingAttributes,
49
+ type MuteTimingProps,
50
+ GrafanaMuteTiming,
51
+ GrafanaMuteTimingProvider,
52
+ } from './mute-timing.ts';
53
+ export { GrafanaProvisionedObjectError } from './provisioned.ts';
54
+ export { GrafanaSecretRefUnsetError } from './secret-refs.ts';
22
55
  export { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
23
56
  export { grafanaProviders } from './providers.ts';
@@ -0,0 +1,212 @@
1
+ /**
2
+ * `Grafana.MessageTemplate` against a fake Grafana — the PUT-is-both-create-and-update route (no
3
+ * `routePostTemplate` exists), the foreign-provenance refusal, and version-carrying updates.
4
+ * Mirrors `message-template.ts`'s own file header.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import { GrafanaProvisionedObjectError } from './alerting-provenance.ts';
9
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
10
+ import { spec } from './message-template.ts';
11
+ import { grafanaOperations } from './resource.ts';
12
+
13
+ const PATH = '/api/v1/provisioning/templates/slack-body';
14
+
15
+ const liveJson = {
16
+ name: 'slack-body',
17
+ provenance: 'api',
18
+ template: '{{ define "slack-body" }}…{{ end }}',
19
+ version: '3',
20
+ };
21
+ const props = { name: 'slack-body', template: '{{ define "slack-body" }}…{{ end }}' };
22
+
23
+ describe('spec.fetchLive', () => {
24
+ test('a live template decodes into typed attributes', async () => {
25
+ const fake = fakeGrafana((method, url) =>
26
+ method === 'GET' && url.pathname === PATH ? Response.json(liveJson) : fakeFailure(404, 'x'),
27
+ );
28
+ const live = await Effect.runPromise(
29
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
30
+ );
31
+ expect(live !== undefined && spec.attributes(live, props)).toEqual({
32
+ name: 'slack-body',
33
+ provenance: 'api',
34
+ template: '{{ define "slack-body" }}…{{ end }}',
35
+ });
36
+ expect(fake.bodies[0]).toBeUndefined();
37
+ });
38
+
39
+ test('a 404 folds to undefined', async () => {
40
+ const fake = fakeGrafana(() => fakeFailure(404, 'template not found'));
41
+ const result = await Effect.runPromise(
42
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
43
+ );
44
+ expect(result).toBeUndefined();
45
+ });
46
+
47
+ test('a 401 propagates — never folded to absent', async () => {
48
+ const fake = fakeGrafana(() => fakeFailure(401, 'bad token'));
49
+ const failure = await Effect.runPromise(
50
+ Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
51
+ );
52
+ expect(failure._tag).toBe('Unauthorized');
53
+ });
54
+ });
55
+
56
+ describe('create and update — the same PUT route', () => {
57
+ test('create sends no version — nothing has been read yet', async () => {
58
+ const fake = fakeGrafana((method, url) =>
59
+ method === 'PUT' && url.pathname === PATH ? Response.json(liveJson) : fakeFailure(404, 'x'),
60
+ );
61
+ await Effect.runPromise(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
62
+ // ⛔ `name` is a URL path label (`/templates/{name}`), never part of the body — only
63
+ // `template` (and, on update, `version`) travel in the JSON payload.
64
+ expect(fake.bodies[0]).toEqual({ template: props.template });
65
+ });
66
+
67
+ test('an api-provenance template updates and sends the live version for concurrency', async () => {
68
+ const changed = { name: 'slack-body', template: 'v2' };
69
+ const fake = fakeGrafana((method, url) => {
70
+ if (method === 'GET' && url.pathname === PATH) return Response.json(liveJson);
71
+ if (method === 'PUT' && url.pathname === PATH)
72
+ return Response.json({ ...liveJson, template: 'v2' });
73
+ return fakeFailure(500, 'unexpected request');
74
+ });
75
+ await Effect.runPromise(
76
+ grafanaOperations(spec)
77
+ .reconcile(changed)
78
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
79
+ );
80
+ const put = fake.seen.findIndex((s) => s.method === 'PUT');
81
+ expect(fake.bodies[put]).toEqual({ template: 'v2', version: '3' });
82
+ });
83
+
84
+ test('reconcile is a noop when the declared template already matches what is live', async () => {
85
+ const fake = fakeGrafana((method, url) =>
86
+ method === 'GET' && url.pathname === PATH
87
+ ? Response.json(liveJson)
88
+ : fakeFailure(500, 'reconcile should not write when nothing changed'),
89
+ );
90
+ const after = await Effect.runPromise(
91
+ grafanaOperations(spec)
92
+ .reconcile(props)
93
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
94
+ );
95
+ expect(after.name).toBe('slack-body');
96
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe('foreign provenance refuses update and destroy', () => {
101
+ test('a file-provisioned template refuses update and sends no PUT', async () => {
102
+ const fileProvisioned = { ...liveJson, provenance: 'file' };
103
+ const fake = fakeGrafana((method, url) =>
104
+ method === 'GET' && url.pathname === PATH
105
+ ? Response.json(fileProvisioned)
106
+ : fakeFailure(500, 'no write should be attempted'),
107
+ );
108
+ const failure = await Effect.runPromise(
109
+ Effect.flip(
110
+ grafanaOperations(spec)
111
+ .reconcile({ name: 'slack-body', template: 'v2' })
112
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
113
+ ),
114
+ );
115
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
116
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
117
+ });
118
+
119
+ test('a file-provisioned template refuses destroy and sends no DELETE', async () => {
120
+ const fileProvisioned = { ...liveJson, provenance: 'file' };
121
+ const fake = fakeGrafana((method, url) =>
122
+ method === 'GET' && url.pathname === PATH
123
+ ? Response.json(fileProvisioned)
124
+ : fakeFailure(500, 'no DELETE should be attempted'),
125
+ );
126
+ const failure = await Effect.runPromise(
127
+ Effect.flip(
128
+ grafanaOperations(spec)
129
+ .destroy(props)
130
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
131
+ ),
132
+ );
133
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
134
+ expect(fake.seen).toEqual([{ method: 'GET', path: PATH }]);
135
+ });
136
+
137
+ test('deletes a live, api-provenance template', async () => {
138
+ const fake = fakeGrafana((method, url) => {
139
+ if (method === 'GET' && url.pathname === PATH) return Response.json(liveJson);
140
+ if (method === 'DELETE' && url.pathname === PATH) return Response.json({});
141
+ return fakeFailure(500, 'unexpected request');
142
+ });
143
+ await Effect.runPromise(
144
+ grafanaOperations(spec)
145
+ .destroy(props)
146
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
147
+ );
148
+ expect(fake.seen).toEqual([
149
+ { method: 'GET', path: PATH },
150
+ { method: 'DELETE', path: PATH },
151
+ ]);
152
+ });
153
+ });
154
+
155
+ describe('missing provenance fails closed', () => {
156
+ // ⛔ THE REGRESSION AN ADVERSARIAL REVIEW OF THIS PR FOUND — see contact-point.test.ts's own
157
+ // copy of this block for the full account.
158
+ const noProvenanceField: Record<string, unknown> = { ...liveJson };
159
+ delete noProvenanceField.provenance;
160
+
161
+ test('a response with no provenance field at all refuses update', async () => {
162
+ const fake = fakeGrafana((method, url) =>
163
+ method === 'GET' && url.pathname === PATH
164
+ ? Response.json(noProvenanceField)
165
+ : fakeFailure(500, 'no write should be attempted'),
166
+ );
167
+ const failure = await Effect.runPromise(
168
+ Effect.flip(
169
+ grafanaOperations(spec)
170
+ .reconcile({ name: 'slack-body', template: 'v2' })
171
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
172
+ ),
173
+ );
174
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
175
+ expect(failure.message).toContain('did not report a provenance');
176
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
177
+ });
178
+
179
+ test('a response with no provenance field at all refuses destroy', async () => {
180
+ const fake = fakeGrafana((method, url) =>
181
+ method === 'GET' && url.pathname === PATH
182
+ ? Response.json(noProvenanceField)
183
+ : fakeFailure(500, 'no DELETE should be attempted'),
184
+ );
185
+ const failure = await Effect.runPromise(
186
+ Effect.flip(
187
+ grafanaOperations(spec)
188
+ .destroy(props)
189
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
190
+ ),
191
+ );
192
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
193
+ expect(fake.seen).toEqual([{ method: 'GET', path: PATH }]);
194
+ });
195
+
196
+ test('an EXPLICIT empty-string provenance (ProvenanceNone) is writable', async () => {
197
+ const explicitlyNone = { ...liveJson, provenance: '' };
198
+ const fake = fakeGrafana((method, url) => {
199
+ if (method === 'GET' && url.pathname === PATH) return Response.json(explicitlyNone);
200
+ if (method === 'PUT' && url.pathname === PATH) {
201
+ return Response.json({ ...explicitlyNone, template: 'v2' });
202
+ }
203
+ return fakeFailure(500, 'unexpected request');
204
+ });
205
+ await Effect.runPromise(
206
+ grafanaOperations(spec)
207
+ .reconcile({ name: 'slack-body', template: 'v2' })
208
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
209
+ );
210
+ expect(fake.seen.some((s) => s.method === 'PUT')).toBe(true);
211
+ });
212
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `Grafana.MessageTemplate` — one Grafana notification-template group, keyed by `name`.
3
+ *
4
+ * ⚠️ CREATE AND UPDATE ARE THE SAME WIRE CALL — MEASURED: `services/grafana.ts` has no
5
+ * `routePostTemplate` at all, only `routePutTemplate` (`PUT /v1/provisioning/templates/{name}`,
6
+ * upsert by name), `routeGetTemplate`/`routeGetTemplates` and `routeDeleteTemplate`. `spec.create`
7
+ * and `spec.update` both call it — kept as two separate functions anyway (rather than omitting
8
+ * `update` and letting them collapse into one) so the shared engine's `diff` reports a content
9
+ * change as `update`, not the `replace` it would show for any resource whose `spec.update` is
10
+ * undefined (`resource.ts`).
11
+ *
12
+ * ⛔ A FOREIGN-PROVENANCE TEMPLATE REFUSES EVERY UPDATE AND DESTROY — `alerting-provenance.ts`,
13
+ * shared with `Grafana.ContactPoint`/`AlertRuleGroup`/`NotificationPolicy`; see that file's
14
+ * header. `create` is never at risk: it only runs when `fetchLive` already found nothing.
15
+ *
16
+ * ⚠️ `version` IS SENT FOR OPTIMISTIC CONCURRENCY WHEN GRAFANA REPORTS ONE — `RoutePutTemplateRequest.
17
+ * version`'s own doc comment: "Version of template to use for optimistic concurrency. Leave empty
18
+ * to disable validation." Unlike `Grafana.Dashboard`'s 412, no live call confirms what error this
19
+ * produces on a genuine mismatch (no live calls in this PR) — whatever typed error the SDK
20
+ * declares for `routePutTemplate` is left uncaught either way, per the house rule against
21
+ * folding a non-not-found error into absent.
22
+ *
23
+ * ⚠️ `X-Disable-Provenance` IS NEVER SET — same reasoning as `contact-point.ts`'s file header.
24
+ */
25
+ import { Resource } from 'alchemy';
26
+ import * as Provider from 'alchemy/Provider';
27
+ import * as grafana from '@distilled.cloud/grafana';
28
+ import * as Effect from 'effect/Effect';
29
+ import {
30
+ type GrafanaProvisionedObjectError,
31
+ isForeignProvenance,
32
+ refuseIfForeignProvenance,
33
+ } from './alerting-provenance.ts';
34
+ import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
35
+
36
+ export interface MessageTemplateProps {
37
+ name: string;
38
+ /** The Go-template body, e.g. Grafana's own "Export as JSON/YAML" template text. */
39
+ template: string;
40
+ }
41
+
42
+ export interface MessageTemplateAttributes {
43
+ name: string;
44
+ template: string;
45
+ /** `""`/`"api"` (this family may write); anything else, INCLUDING `undefined` (Grafana didn't
46
+ * report the field), refuses — see alerting-provenance.ts's "fails open" note. */
47
+ provenance: string | undefined;
48
+ }
49
+
50
+ export interface GrafanaMessageTemplate extends Resource<
51
+ 'Grafana.MessageTemplate',
52
+ MessageTemplateProps,
53
+ MessageTemplateAttributes,
54
+ never
55
+ > {}
56
+
57
+ export const GrafanaMessageTemplate = Resource<GrafanaMessageTemplate>('Grafana.MessageTemplate');
58
+
59
+ export const spec: GrafanaSpec<
60
+ MessageTemplateProps,
61
+ grafana.NotificationTemplate,
62
+ MessageTemplateAttributes,
63
+ | grafana.RouteGetTemplateError
64
+ | grafana.RoutePutTemplateError
65
+ | grafana.RouteDeleteTemplateError
66
+ | GrafanaProvisionedObjectError
67
+ > = {
68
+ attributes: (live) => ({
69
+ name: live.name ?? '',
70
+ // ⛔ NEVER `?? ''` — see alerting-provenance.ts's "fails open" note.
71
+ provenance: live.provenance,
72
+ template: live.template ?? '',
73
+ }),
74
+ create: (props) => grafana.routePutTemplate({ name: props.name, template: props.template }),
75
+ destroy: (props, live) =>
76
+ Effect.gen(function* () {
77
+ if (isForeignProvenance(live.provenance)) {
78
+ return yield* refuseIfForeignProvenance(
79
+ 'Grafana.MessageTemplate',
80
+ props.name,
81
+ live.provenance,
82
+ );
83
+ }
84
+ yield* grafana.routeDeleteTemplate({ name: props.name });
85
+ }),
86
+ fetchLive: (props) =>
87
+ grafana
88
+ .routeGetTemplate({ name: props.name })
89
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
90
+ matches: (attributes, props) => attributes.template === props.template,
91
+ update: (props, live) =>
92
+ Effect.gen(function* () {
93
+ if (isForeignProvenance(live.provenance)) {
94
+ return yield* refuseIfForeignProvenance(
95
+ 'Grafana.MessageTemplate',
96
+ props.name,
97
+ live.provenance,
98
+ );
99
+ }
100
+ yield* grafana.routePutTemplate({
101
+ name: props.name,
102
+ template: props.template,
103
+ ...(live.version === undefined ? {} : { version: live.version }),
104
+ });
105
+ }),
106
+ };
107
+
108
+ export const handlers = grafanaHandlers(spec);
109
+
110
+ export const GrafanaMessageTemplateProvider = () =>
111
+ Provider.effect(
112
+ GrafanaMessageTemplate,
113
+ Effect.succeed(GrafanaMessageTemplate.Provider.of(handlers)),
114
+ );