@homeflare/alchemy 0.33.0 → 0.35.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 (86) hide show
  1. package/dist/grafana/alert-rule-group-drop-warning.d.ts +25 -0
  2. package/dist/grafana/alert-rule-group-drop-warning.d.ts.map +1 -0
  3. package/dist/grafana/alert-rule-group-fixtures.d.ts +73 -0
  4. package/dist/grafana/alert-rule-group-fixtures.d.ts.map +1 -0
  5. package/dist/grafana/alert-rule-group-model.d.ts +28 -0
  6. package/dist/grafana/alert-rule-group-model.d.ts.map +1 -0
  7. package/dist/grafana/alert-rule-group.d.ts +60 -0
  8. package/dist/grafana/alert-rule-group.d.ts.map +1 -0
  9. package/dist/grafana/alerting-provenance.d.ts +80 -0
  10. package/dist/grafana/alerting-provenance.d.ts.map +1 -0
  11. package/dist/grafana/contact-point-fixtures.d.ts +28 -0
  12. package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
  13. package/dist/grafana/contact-point.d.ts +99 -0
  14. package/dist/grafana/contact-point.d.ts.map +1 -0
  15. package/dist/grafana/dashboard-model.d.ts +8 -38
  16. package/dist/grafana/dashboard-model.d.ts.map +1 -1
  17. package/dist/grafana/datasource.d.ts +2 -9
  18. package/dist/grafana/datasource.d.ts.map +1 -1
  19. package/dist/grafana/index.d.ts +11 -4
  20. package/dist/grafana/index.d.ts.map +1 -1
  21. package/dist/grafana/index.js +339 -80
  22. package/dist/grafana/index.js.map +16 -7
  23. package/dist/grafana/message-template.d.ts +70 -0
  24. package/dist/grafana/message-template.d.ts.map +1 -0
  25. package/dist/grafana/mute-timing.d.ts +112 -0
  26. package/dist/grafana/mute-timing.d.ts.map +1 -0
  27. package/dist/grafana/providers.d.ts +1 -1
  28. package/dist/grafana/providers.d.ts.map +1 -1
  29. package/dist/grafana/secret-refs.d.ts +19 -0
  30. package/dist/grafana/secret-refs.d.ts.map +1 -0
  31. package/dist/grafana/subset-match.d.ts +2 -0
  32. package/dist/grafana/subset-match.d.ts.map +1 -0
  33. package/dist/linux/index.d.ts +1 -1
  34. package/dist/linux/index.d.ts.map +1 -1
  35. package/dist/linux/index.js +3 -1
  36. package/dist/linux/index.js.map +1 -1
  37. package/dist/proxmox/index.js +349 -287
  38. package/dist/proxmox/index.js.map +8 -7
  39. package/dist/proxmox/network-apply-read.d.ts +12 -0
  40. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  41. package/dist/proxmox/network-apply.d.ts +2 -0
  42. package/dist/proxmox/network-apply.d.ts.map +1 -1
  43. package/dist/proxmox/node-network-form.d.ts +86 -54
  44. package/dist/proxmox/node-network-form.d.ts.map +1 -1
  45. package/dist/proxmox/node-network-wire.d.ts +100 -0
  46. package/dist/proxmox/node-network-wire.d.ts.map +1 -0
  47. package/dist/proxmox/node-network.d.ts +21 -106
  48. package/dist/proxmox/node-network.d.ts.map +1 -1
  49. package/docs/grafana-alerting-rules.md +143 -0
  50. package/docs/grafana-alerting.md +147 -0
  51. package/docs/grafana.md +6 -3
  52. package/docs/upstream-conformance.md +38 -4
  53. package/package.json +1 -1
  54. package/src/grafana/alert-rule-group-drop-warning.test.ts +107 -0
  55. package/src/grafana/alert-rule-group-drop-warning.ts +41 -0
  56. package/src/grafana/alert-rule-group-fixtures.ts +46 -0
  57. package/src/grafana/alert-rule-group-model.test.ts +75 -0
  58. package/src/grafana/alert-rule-group-model.ts +66 -0
  59. package/src/grafana/alert-rule-group-provenance.test.ts +116 -0
  60. package/src/grafana/alert-rule-group.test.ts +164 -0
  61. package/src/grafana/alert-rule-group.ts +231 -0
  62. package/src/grafana/alerting-provenance.ts +109 -0
  63. package/src/grafana/contact-point-fixtures.ts +25 -0
  64. package/src/grafana/contact-point-provenance.test.ts +113 -0
  65. package/src/grafana/contact-point-secrets.test.ts +117 -0
  66. package/src/grafana/contact-point.test.ts +123 -0
  67. package/src/grafana/contact-point.ts +179 -0
  68. package/src/grafana/dashboard-model.ts +8 -57
  69. package/src/grafana/datasource.ts +5 -27
  70. package/src/grafana/index.ts +31 -4
  71. package/src/grafana/message-template.test.ts +212 -0
  72. package/src/grafana/message-template.ts +114 -0
  73. package/src/grafana/mute-timing.test.ts +227 -0
  74. package/src/grafana/mute-timing.ts +160 -0
  75. package/src/grafana/providers.ts +8 -0
  76. package/src/grafana/secret-refs.ts +48 -0
  77. package/src/grafana/subset-match.ts +71 -0
  78. package/src/linux/index-export.test.ts +25 -0
  79. package/src/linux/index.ts +15 -1
  80. package/src/proxmox/constraints-host-forms.test.ts +2 -2
  81. package/src/proxmox/network-apply-read.ts +12 -0
  82. package/src/proxmox/network-apply.ts +2 -0
  83. package/src/proxmox/node-network-form.ts +93 -99
  84. package/src/proxmox/node-network-read-failure.test.ts +206 -0
  85. package/src/proxmox/node-network-wire.ts +239 -0
  86. package/src/proxmox/node-network.ts +155 -167
@@ -50,62 +50,13 @@ export const normalizeModel = (model: DashboardModel, uid: string): DashboardMod
50
50
  export const modelTitle = (model: DashboardModel): string =>
51
51
  typeof model.title === 'string' ? model.title : '';
52
52
 
53
- const isPlainObject = (value: unknown): value is Record<string, unknown> =>
54
- typeof value === 'object' && value !== null && !Array.isArray(value);
55
-
56
53
  /**
57
- * ★ `declared` NEED ONLY BE A SUBSET OF `live` — an adversarial review of this PR (2026-09-24)
58
- * found that plain structural equality made an ordinary, never-edited dashboard show `update`
59
- * FOREVER: Grafana's own dashboard-save schema migration rewrites any panel or target whose
60
- * `datasource` is absent into an explicit `{type, uid}` reference to the resolved default
61
- * datasource — documented Grafana schema-migration behavior (not re-measured against a live
62
- * instance in this PR — no live calls). A declared panel that never sets `datasource` (the
63
- * common case: hand-authored JSON, or anything relying on the dashboard-level default) would,
64
- * after the FIRST create, read back with that field populated; plain `deepEqual` (this file used
65
- * `alchemy/Diff`'s before this fix) then disagreed with the declaration on every subsequent plan,
66
- * `update` sent the undecorated model back, and Grafana redecorated it again on the next read —
67
- * non-convergent.
68
- *
69
- * This function instead asks only "does every field the DECLARATION mentions, at any depth, still
70
- * match what is live" — `live` may carry additional fields, at any depth, that `declared` never
71
- * mentioned (the datasource auto-decoration above, or any future Grafana-injected default,
72
- * `fieldConfig` defaults and `pluginVersion` included) without being compared at all. A field
73
- * `declared` DOES mention that is missing, or different, in `live` still fails the match — real
74
- * content drift is still caught exactly as before; only fields the declaration is silent about are
75
- * now tolerated rather than compared.
76
- *
77
- * ⚠️ THE TRADE-OFF THIS ACCEPTS, DELIBERATELY: removing a field from the declaration (rather than
78
- * changing its value) can no longer force Grafana to drop it. If a panel once declared an
79
- * explicit `datasource` and a later declaration deletes that key entirely (rather than setting a
80
- * different one), `live` keeps whatever the last write set — the declaration is silent about that
81
- * key now, so nothing compares it, so no `update` ever fires to change it. This is the same
82
- * "undeclared means don't care, never means force-to-default" convention already used everywhere
83
- * else in this kit for a plain optional prop (e.g. `datasource.ts`'s `matches` never treats an
84
- * undeclared `access` as "must be unset"); it now also applies inside the JSON model, not just at
85
- * the props level. Declare an explicit replacement value to change a field; omission never clears
86
- * one that was set by a previous declaration or by Grafana itself.
87
- *
88
- * ⚠️ ARRAYS COMPARE BY LENGTH, THEN ELEMENT-WISE SUBSET — never by a looser "declared has fewer
89
- * items" rule. `panels`/`targets` are position-significant (see `normalizeModel`'s header); an
90
- * array Grafana pads or reorders on its own would still need normalizing here explicitly, the
91
- * same as any other newly-discovered auto-decoration — this function does not paper over that.
92
- * ⚠️ `null` ON EITHER SIDE OF A LEAF NEVER MISMATCHES (mirrors `alchemy/Diff`'s `deepEqual` with
93
- * `stripNullish: true`, which this function replaces for the dashboard model specifically).
54
+ * `declared` need only be a subset of `live` — dashboard.ts's `matches` runs `normalizeModel`'s
55
+ * output through this. Moved to `subset-match.ts` once `Grafana.ContactPoint`'s `settings` needed
56
+ * the identical "declaration silent about a field means don't compare it" rule; re-exported here
57
+ * under its original name so this file's own callers and dashboard-model.test.ts are unchanged.
58
+ * See subset-match.ts for the full reasoning, including the regression this exists to fix
59
+ * (Grafana's dashboard-save schema migration auto-decorating a panel's `datasource`) and the
60
+ * trade-off it accepts (an omitted field can no longer force-clear a previously-set one).
94
61
  */
95
- export const declaredContentMatches = (declared: unknown, live: unknown): boolean => {
96
- if (declared == null) return true;
97
- if (Array.isArray(declared)) {
98
- return (
99
- Array.isArray(live) &&
100
- declared.length === live.length &&
101
- declared.every((item, index) => declaredContentMatches(item, live[index]))
102
- );
103
- }
104
- if (isPlainObject(declared)) {
105
- return (
106
- isPlainObject(live) &&
107
- Object.entries(declared).every(([key, value]) => declaredContentMatches(value, live[key]))
108
- );
109
- }
110
- return declared === live;
111
- };
62
+ export { declaredContentMatches } from './subset-match.ts';
@@ -20,9 +20,11 @@
20
20
  import { Resource } from 'alchemy';
21
21
  import * as Provider from 'alchemy/Provider';
22
22
  import * as grafana from '@distilled.cloud/grafana';
23
- import * as Data from 'effect/Data';
24
23
  import * as Effect from 'effect/Effect';
25
24
  import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
25
+ import { GrafanaSecretRefUnsetError, resolveSecretRefs } from './secret-refs.ts';
26
+
27
+ export { GrafanaSecretRefUnsetError };
26
28
 
27
29
  export interface DatasourceProps {
28
30
  uid: string;
@@ -64,33 +66,9 @@ export interface GrafanaDatasource extends Resource<
64
66
 
65
67
  export const GrafanaDatasource = Resource<GrafanaDatasource>('Grafana.Datasource');
66
68
 
67
- /** A named secure-field env var is unset — a domain refusal, not a distilled error. */
68
- export class GrafanaSecretRefUnsetError extends Data.TaggedError('GrafanaSecretRefUnsetError')<{
69
- readonly message: string;
70
- }> {}
71
-
72
69
  /** Reads every named env var fresh, inside the caller's effect (S24) — never at plan time. */
73
- const resolveSecureJsonData = (
74
- refs: Record<string, string> | undefined,
75
- ): Effect.Effect<Record<string, string> | undefined, GrafanaSecretRefUnsetError> =>
76
- Effect.gen(function* () {
77
- if (refs === undefined) return undefined;
78
- const out: Record<string, string> = {};
79
- for (const [field, envVar] of Object.entries(refs)) {
80
- const raw = process.env[envVar];
81
- if (raw === undefined || raw.trim() === '') {
82
- return yield* Effect.fail(
83
- new GrafanaSecretRefUnsetError({
84
- message:
85
- `${envVar} is unset. Export the value of secureJsonData.${field} there — ` +
86
- 'never as an Alchemy prop.',
87
- }),
88
- );
89
- }
90
- out[field] = raw.trim();
91
- }
92
- return out;
93
- });
70
+ const resolveSecureJsonData = (refs: Record<string, string> | undefined) =>
71
+ resolveSecretRefs(refs, (field) => `secureJsonData.${field}`);
94
72
 
95
73
  /** `exactOptionalPropertyTypes` means an undeclared field must be OMITTED, never sent as `undefined`. */
96
74
  const fields = (props: DatasourceProps) => ({
@@ -6,16 +6,24 @@
6
6
  * dashboard-model.ts, fake-grafana.ts) are internals a provider needs but a consumer should not
7
7
  * depend on.
8
8
  *
9
- * `Grafana.Datasource`, `Grafana.Folder` and `Grafana.Dashboard` ship here. `Grafana.AlertRule`
10
- * and friends are still follow-up work on the same now-available SDK operations — see
11
- * docs/grafana.md and docs/grafana-folder-dashboard.md for what shipped and what is still open.
9
+ * `Grafana.Datasource`, `Grafana.Folder`, `Grafana.Dashboard`, `Grafana.ContactPoint`,
10
+ * `Grafana.MuteTiming`, `Grafana.MessageTemplate` and `Grafana.AlertRuleGroup` ship here.
11
+ * `Grafana.NotificationPolicy` — the singleton policy tree — is a stacked follow-up PR on the same
12
+ * SDK operations — see docs/grafana.md, docs/grafana-folder-dashboard.md and
13
+ * docs/grafana-alerting.md for what shipped and what is still open.
12
14
  */
15
+ export {
16
+ type AlertRuleGroupAttributes,
17
+ type AlertRuleGroupProps,
18
+ type AlertRuleInput,
19
+ GrafanaAlertRuleGroup,
20
+ GrafanaAlertRuleGroupProvider,
21
+ } from './alert-rule-group.ts';
13
22
  export {
14
23
  type DatasourceAttributes,
15
24
  type DatasourceProps,
16
25
  GrafanaDatasource,
17
26
  GrafanaDatasourceProvider,
18
- GrafanaSecretRefUnsetError,
19
27
  } from './datasource.ts';
20
28
  export {
21
29
  type DashboardAttributes,
@@ -31,6 +39,25 @@ export {
31
39
  GrafanaFolderProvider,
32
40
  GrafanaFolderReparentError,
33
41
  } from './folder.ts';
42
+ export {
43
+ type ContactPointAttributes,
44
+ type ContactPointProps,
45
+ GrafanaContactPoint,
46
+ GrafanaContactPointProvider,
47
+ } from './contact-point.ts';
48
+ export {
49
+ type MessageTemplateAttributes,
50
+ type MessageTemplateProps,
51
+ GrafanaMessageTemplate,
52
+ GrafanaMessageTemplateProvider,
53
+ } from './message-template.ts';
54
+ export {
55
+ type MuteTimingAttributes,
56
+ type MuteTimingProps,
57
+ GrafanaMuteTiming,
58
+ GrafanaMuteTimingProvider,
59
+ } from './mute-timing.ts';
34
60
  export { GrafanaProvisionedObjectError } from './provisioned.ts';
61
+ export { GrafanaSecretRefUnsetError } from './secret-refs.ts';
35
62
  export { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
36
63
  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
+ );