@homeflare/alchemy 0.33.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.
- package/dist/grafana/alerting-provenance.d.ts +80 -0
- package/dist/grafana/alerting-provenance.d.ts.map +1 -0
- package/dist/grafana/contact-point-fixtures.d.ts +28 -0
- package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
- package/dist/grafana/contact-point.d.ts +99 -0
- package/dist/grafana/contact-point.d.ts.map +1 -0
- package/dist/grafana/dashboard-model.d.ts +8 -38
- package/dist/grafana/dashboard-model.d.ts.map +1 -1
- package/dist/grafana/datasource.d.ts +2 -9
- package/dist/grafana/datasource.d.ts.map +1 -1
- package/dist/grafana/index.d.ts +10 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +198 -41
- package/dist/grafana/index.js.map +12 -6
- package/dist/grafana/message-template.d.ts +70 -0
- package/dist/grafana/message-template.d.ts.map +1 -0
- package/dist/grafana/mute-timing.d.ts +112 -0
- package/dist/grafana/mute-timing.d.ts.map +1 -0
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/secret-refs.d.ts +19 -0
- package/dist/grafana/secret-refs.d.ts.map +1 -0
- package/dist/grafana/subset-match.d.ts +2 -0
- package/dist/grafana/subset-match.d.ts.map +1 -0
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +3 -1
- package/dist/linux/index.js.map +1 -1
- package/docs/grafana-alerting.md +145 -0
- package/docs/grafana.md +4 -3
- package/docs/upstream-conformance.md +26 -4
- package/package.json +1 -1
- package/src/grafana/alerting-provenance.ts +109 -0
- package/src/grafana/contact-point-fixtures.ts +25 -0
- package/src/grafana/contact-point-provenance.test.ts +113 -0
- package/src/grafana/contact-point-secrets.test.ts +117 -0
- package/src/grafana/contact-point.test.ts +123 -0
- package/src/grafana/contact-point.ts +179 -0
- package/src/grafana/dashboard-model.ts +8 -57
- package/src/grafana/datasource.ts +5 -27
- package/src/grafana/index.ts +24 -4
- package/src/grafana/message-template.test.ts +212 -0
- package/src/grafana/message-template.ts +114 -0
- package/src/grafana/mute-timing.test.ts +227 -0
- package/src/grafana/mute-timing.ts +160 -0
- package/src/grafana/providers.ts +6 -0
- package/src/grafana/secret-refs.ts +48 -0
- package/src/grafana/subset-match.ts +71 -0
- package/src/linux/index-export.test.ts +25 -0
- package/src/linux/index.ts +15 -1
package/src/grafana/index.ts
CHANGED
|
@@ -6,16 +6,17 @@
|
|
|
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
|
|
10
|
-
* and
|
|
11
|
-
*
|
|
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.
|
|
12
14
|
*/
|
|
13
15
|
export {
|
|
14
16
|
type DatasourceAttributes,
|
|
15
17
|
type DatasourceProps,
|
|
16
18
|
GrafanaDatasource,
|
|
17
19
|
GrafanaDatasourceProvider,
|
|
18
|
-
GrafanaSecretRefUnsetError,
|
|
19
20
|
} from './datasource.ts';
|
|
20
21
|
export {
|
|
21
22
|
type DashboardAttributes,
|
|
@@ -31,6 +32,25 @@ export {
|
|
|
31
32
|
GrafanaFolderProvider,
|
|
32
33
|
GrafanaFolderReparentError,
|
|
33
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';
|
|
34
53
|
export { GrafanaProvisionedObjectError } from './provisioned.ts';
|
|
54
|
+
export { GrafanaSecretRefUnsetError } from './secret-refs.ts';
|
|
35
55
|
export { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
|
|
36
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
|
+
);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.MuteTiming` against a fake Grafana — the widened-type `provenance`/`version` read (see
|
|
3
|
+
* the file's own header for why the SDK's declared `MuteTimeInterval` type undersells the runtime
|
|
4
|
+
* value), the foreign-provenance refusal, and opaque time-interval passthrough (weekdays/times
|
|
5
|
+
* fields the SDK's own `TimeInterval` type does not model either). Mirrors `contact-point.test.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import { GrafanaProvisionedObjectError } from './alerting-provenance.ts';
|
|
10
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
11
|
+
import { spec } from './mute-timing.ts';
|
|
12
|
+
import { grafanaOperations } from './resource.ts';
|
|
13
|
+
|
|
14
|
+
const PATH = '/api/v1/provisioning/mute-timings/weekends';
|
|
15
|
+
const LIST_PATH = '/api/v1/provisioning/mute-timings';
|
|
16
|
+
|
|
17
|
+
const timeIntervals = [
|
|
18
|
+
{ times: [{ end_time: '09:00', start_time: '17:00' }], weekdays: ['friday'] },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const liveJson = {
|
|
22
|
+
name: 'weekends',
|
|
23
|
+
provenance: 'api',
|
|
24
|
+
time_intervals: timeIntervals,
|
|
25
|
+
version: 'v1',
|
|
26
|
+
};
|
|
27
|
+
const props = { name: 'weekends', timeIntervals };
|
|
28
|
+
|
|
29
|
+
describe('spec.fetchLive', () => {
|
|
30
|
+
test('provenance and the real weekday/time fields survive decode despite the SDK type', async () => {
|
|
31
|
+
const fake = fakeGrafana((method, url) =>
|
|
32
|
+
method === 'GET' && url.pathname === PATH ? Response.json(liveJson) : fakeFailure(404, 'x'),
|
|
33
|
+
);
|
|
34
|
+
const live = await Effect.runPromise(
|
|
35
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
36
|
+
);
|
|
37
|
+
expect(live !== undefined && spec.attributes(live, props)).toEqual({
|
|
38
|
+
name: 'weekends',
|
|
39
|
+
provenance: 'api',
|
|
40
|
+
timeIntervals,
|
|
41
|
+
});
|
|
42
|
+
expect(fake.bodies[0]).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a 404 folds to undefined', async () => {
|
|
46
|
+
const fake = fakeGrafana(() => fakeFailure(404, 'mute timing not found'));
|
|
47
|
+
const result = await Effect.runPromise(
|
|
48
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
49
|
+
);
|
|
50
|
+
expect(result).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a 403 propagates — never folded to absent', async () => {
|
|
54
|
+
const fake = fakeGrafana(() => fakeFailure(403, 'forbidden'));
|
|
55
|
+
const failure = await Effect.runPromise(
|
|
56
|
+
Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
|
|
57
|
+
);
|
|
58
|
+
expect(failure._tag).toBe('Forbidden');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('create', () => {
|
|
63
|
+
test('sends the real weekday/time fields on the wire — not dropped by the SDK type either', async () => {
|
|
64
|
+
const fake = fakeGrafana((method, url) =>
|
|
65
|
+
method === 'POST' && url.pathname === LIST_PATH
|
|
66
|
+
? Response.json(liveJson)
|
|
67
|
+
: fakeFailure(404, 'x'),
|
|
68
|
+
);
|
|
69
|
+
await Effect.runPromise(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
|
|
70
|
+
expect(fake.bodies[0]).toEqual({ name: 'weekends', time_intervals: timeIntervals });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('subset-match array rule — pinned, not re-measured live', () => {
|
|
75
|
+
// ⛔ FLAGGED BY AN ADVERSARIAL REVIEW OF THIS PR: `subset-match.ts`'s array comparison is exact
|
|
76
|
+
// length, then element-wise — unverified against a real Grafana instance for whether it ever
|
|
77
|
+
// pads or appends a default entry to `time_intervals` (no live mute timing exists in this house
|
|
78
|
+
// to observe). This test pins TODAY's behavior — a live observation that finds Grafana doing so
|
|
79
|
+
// would make this test itself the place to change, not a surprise discovered elsewhere.
|
|
80
|
+
test('live carrying one extra time-interval entry the declaration never mentioned still shows update', async () => {
|
|
81
|
+
const extraEntry = {
|
|
82
|
+
...liveJson,
|
|
83
|
+
time_intervals: [...timeIntervals, { weekdays: ['sunday'] }],
|
|
84
|
+
};
|
|
85
|
+
const fake = fakeGrafana((method, url) =>
|
|
86
|
+
method === 'GET' && url.pathname === PATH ? Response.json(extraEntry) : fakeFailure(500, 'x'),
|
|
87
|
+
);
|
|
88
|
+
const live = await Effect.runPromise(
|
|
89
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
90
|
+
);
|
|
91
|
+
const attrs = live !== undefined ? spec.attributes(live, props) : undefined;
|
|
92
|
+
expect(attrs !== undefined && spec.matches(attrs, props)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('reconcile — adopt semantics', () => {
|
|
97
|
+
test('declaring exactly what is live is a noop: no write is ever sent', async () => {
|
|
98
|
+
const fake = fakeGrafana((method, url) =>
|
|
99
|
+
method === 'GET' && url.pathname === PATH
|
|
100
|
+
? Response.json(liveJson)
|
|
101
|
+
: fakeFailure(500, 'reconcile should not write when nothing changed'),
|
|
102
|
+
);
|
|
103
|
+
const after = await Effect.runPromise(
|
|
104
|
+
grafanaOperations(spec)
|
|
105
|
+
.reconcile(props)
|
|
106
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
107
|
+
);
|
|
108
|
+
expect(after.name).toBe('weekends');
|
|
109
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('foreign provenance refuses update and destroy', () => {
|
|
114
|
+
test('a file-provisioned mute timing refuses update and sends no PUT', async () => {
|
|
115
|
+
const fileProvisioned = { ...liveJson, provenance: 'file' };
|
|
116
|
+
const fake = fakeGrafana((method, url) =>
|
|
117
|
+
method === 'GET' && url.pathname === PATH
|
|
118
|
+
? Response.json(fileProvisioned)
|
|
119
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
120
|
+
);
|
|
121
|
+
const changed = { name: 'weekends', timeIntervals: [{ weekdays: ['saturday'] }] };
|
|
122
|
+
const failure = await Effect.runPromise(
|
|
123
|
+
Effect.flip(
|
|
124
|
+
grafanaOperations(spec)
|
|
125
|
+
.reconcile(changed)
|
|
126
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
130
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a file-provisioned mute timing refuses destroy and sends no DELETE', async () => {
|
|
134
|
+
const fileProvisioned = { ...liveJson, provenance: 'file' };
|
|
135
|
+
const fake = fakeGrafana((method, url) =>
|
|
136
|
+
method === 'GET' && url.pathname === PATH
|
|
137
|
+
? Response.json(fileProvisioned)
|
|
138
|
+
: fakeFailure(500, 'no DELETE should be attempted'),
|
|
139
|
+
);
|
|
140
|
+
const failure = await Effect.runPromise(
|
|
141
|
+
Effect.flip(
|
|
142
|
+
grafanaOperations(spec)
|
|
143
|
+
.destroy(props)
|
|
144
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
148
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: PATH }]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('deletes a live, api-provenance mute timing, sending its version for concurrency', async () => {
|
|
152
|
+
const fake = fakeGrafana((method, url) => {
|
|
153
|
+
if (method === 'GET' && url.pathname === PATH) return Response.json(liveJson);
|
|
154
|
+
if (method === 'DELETE' && url.pathname === PATH) return Response.json({});
|
|
155
|
+
return fakeFailure(500, 'unexpected request');
|
|
156
|
+
});
|
|
157
|
+
await Effect.runPromise(
|
|
158
|
+
grafanaOperations(spec)
|
|
159
|
+
.destroy(props)
|
|
160
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
161
|
+
);
|
|
162
|
+
const del = fake.seen.findIndex((s) => s.method === 'DELETE');
|
|
163
|
+
expect(del).toBeGreaterThanOrEqual(0);
|
|
164
|
+
expect(fake.seen[del]?.path).toBe(`${PATH}?version=v1`);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('missing provenance fails closed', () => {
|
|
169
|
+
// ⛔ THE REGRESSION AN ADVERSARIAL REVIEW OF THIS PR FOUND — see contact-point.test.ts's own
|
|
170
|
+
// copy of this block for the full account. This resource is the one where it matters most:
|
|
171
|
+
// `provenance` isn't even in the SDK's declared `MuteTimeInterval` type, so "the field is
|
|
172
|
+
// missing" is a real, plausible shape for a response to take, not a hypothetical.
|
|
173
|
+
const noProvenanceField: Record<string, unknown> = { ...liveJson };
|
|
174
|
+
delete noProvenanceField.provenance;
|
|
175
|
+
|
|
176
|
+
test('a response with no provenance field at all refuses update', async () => {
|
|
177
|
+
const fake = fakeGrafana((method, url) =>
|
|
178
|
+
method === 'GET' && url.pathname === PATH
|
|
179
|
+
? Response.json(noProvenanceField)
|
|
180
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
181
|
+
);
|
|
182
|
+
const changed = { name: 'weekends', timeIntervals: [{ weekdays: ['saturday'] }] };
|
|
183
|
+
const failure = await Effect.runPromise(
|
|
184
|
+
Effect.flip(
|
|
185
|
+
grafanaOperations(spec)
|
|
186
|
+
.reconcile(changed)
|
|
187
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
191
|
+
expect(failure.message).toContain('did not report a provenance');
|
|
192
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a response with no provenance field at all refuses destroy', async () => {
|
|
196
|
+
const fake = fakeGrafana((method, url) =>
|
|
197
|
+
method === 'GET' && url.pathname === PATH
|
|
198
|
+
? Response.json(noProvenanceField)
|
|
199
|
+
: fakeFailure(500, 'no DELETE should be attempted'),
|
|
200
|
+
);
|
|
201
|
+
const failure = await Effect.runPromise(
|
|
202
|
+
Effect.flip(
|
|
203
|
+
grafanaOperations(spec)
|
|
204
|
+
.destroy(props)
|
|
205
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
209
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: PATH }]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('an EXPLICIT empty-string provenance (ProvenanceNone) is writable', async () => {
|
|
213
|
+
const explicitlyNone = { ...liveJson, provenance: '' };
|
|
214
|
+
const fake = fakeGrafana((method, url) => {
|
|
215
|
+
if (method === 'GET' && url.pathname === PATH) return Response.json(explicitlyNone);
|
|
216
|
+
if (method === 'PUT' && url.pathname === PATH) return Response.json(explicitlyNone);
|
|
217
|
+
return fakeFailure(500, 'unexpected request');
|
|
218
|
+
});
|
|
219
|
+
const changed = { name: 'weekends', timeIntervals: [{ weekdays: ['saturday'] }] };
|
|
220
|
+
await Effect.runPromise(
|
|
221
|
+
grafanaOperations(spec)
|
|
222
|
+
.reconcile(changed)
|
|
223
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
224
|
+
);
|
|
225
|
+
expect(fake.seen.some((s) => s.method === 'PUT')).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
});
|