@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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.ContactPoint`'s provenance refusal — both the explicit-foreign case (`provenance:
|
|
3
|
+
* 'file'`) and the missing-field-fails-closed case an adversarial review of this PR found. Split
|
|
4
|
+
* out of `contact-point.test.ts` to keep each file under the house's 250-line cap.
|
|
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 { ITEM_PATH, LIST_PATH, liveJson, props } from './contact-point-fixtures.ts';
|
|
10
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
11
|
+
import { grafanaOperations } from './resource.ts';
|
|
12
|
+
import { spec } from './contact-point.ts';
|
|
13
|
+
|
|
14
|
+
describe('update and destroy — foreign provenance refuses', () => {
|
|
15
|
+
test('a file-provisioned contact point refuses update and sends no PUT', async () => {
|
|
16
|
+
const fileProvisioned = { ...liveJson, provenance: 'file' };
|
|
17
|
+
const fake = fakeGrafana((method, url) =>
|
|
18
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
19
|
+
? Response.json([fileProvisioned])
|
|
20
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
21
|
+
);
|
|
22
|
+
const failure = await Effect.runPromise(
|
|
23
|
+
Effect.flip(
|
|
24
|
+
grafanaOperations(spec)
|
|
25
|
+
.reconcile({ ...props, name: 'Renamed' })
|
|
26
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
30
|
+
expect(failure.message).toContain('file');
|
|
31
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a file-provisioned contact point refuses destroy and sends no DELETE', async () => {
|
|
35
|
+
const fileProvisioned = { ...liveJson, provenance: 'file' };
|
|
36
|
+
const fake = fakeGrafana((method, url) =>
|
|
37
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
38
|
+
? Response.json([fileProvisioned])
|
|
39
|
+
: fakeFailure(500, 'no DELETE should be attempted'),
|
|
40
|
+
);
|
|
41
|
+
const failure = await Effect.runPromise(
|
|
42
|
+
Effect.flip(
|
|
43
|
+
grafanaOperations(spec)
|
|
44
|
+
.destroy(props)
|
|
45
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
49
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: LIST_PATH }]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('missing provenance fails closed', () => {
|
|
54
|
+
// ⛔ THE REGRESSION AN ADVERSARIAL REVIEW OF THIS PR FOUND. An earlier version mapped
|
|
55
|
+
// `live.provenance ?? ''`, so a response that simply omitted the field looked identical to an
|
|
56
|
+
// explicit `ProvenanceNone` and was treated as writable — the guard failed OPEN. These three
|
|
57
|
+
// tests pin the fix: missing refuses (both writes), an EXPLICIT `""` is the only thing that
|
|
58
|
+
// allows one.
|
|
59
|
+
const noProvenanceField: Record<string, unknown> = { ...liveJson };
|
|
60
|
+
delete noProvenanceField.provenance;
|
|
61
|
+
|
|
62
|
+
test('a response with no provenance field at all refuses update', async () => {
|
|
63
|
+
const fake = fakeGrafana((method, url) =>
|
|
64
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
65
|
+
? Response.json([noProvenanceField])
|
|
66
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
67
|
+
);
|
|
68
|
+
const failure = await Effect.runPromise(
|
|
69
|
+
Effect.flip(
|
|
70
|
+
grafanaOperations(spec)
|
|
71
|
+
.reconcile({ ...props, name: 'Renamed' })
|
|
72
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
76
|
+
expect(failure.message).toContain('did not report a provenance');
|
|
77
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a response with no provenance field at all refuses destroy', async () => {
|
|
81
|
+
const fake = fakeGrafana((method, url) =>
|
|
82
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
83
|
+
? Response.json([noProvenanceField])
|
|
84
|
+
: fakeFailure(500, 'no DELETE should be attempted'),
|
|
85
|
+
);
|
|
86
|
+
const failure = await Effect.runPromise(
|
|
87
|
+
Effect.flip(
|
|
88
|
+
grafanaOperations(spec)
|
|
89
|
+
.destroy(props)
|
|
90
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
94
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: LIST_PATH }]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('an EXPLICIT empty-string provenance (ProvenanceNone) is writable', async () => {
|
|
98
|
+
const explicitlyNone = { ...liveJson, provenance: '' };
|
|
99
|
+
const fake = fakeGrafana((method, url) => {
|
|
100
|
+
if (method === 'GET' && url.pathname === LIST_PATH) return Response.json([explicitlyNone]);
|
|
101
|
+
if (method === 'PUT' && url.pathname === ITEM_PATH) {
|
|
102
|
+
return Response.json({ ...explicitlyNone, name: 'Renamed' });
|
|
103
|
+
}
|
|
104
|
+
return fakeFailure(500, 'unexpected request');
|
|
105
|
+
});
|
|
106
|
+
await Effect.runPromise(
|
|
107
|
+
grafanaOperations(spec)
|
|
108
|
+
.reconcile({ ...props, name: 'Renamed' })
|
|
109
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
110
|
+
);
|
|
111
|
+
expect(fake.seen.some((s) => s.method === 'PUT' && s.path === ITEM_PATH)).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.ContactPoint`'s `secureSettingsRefs` seam (S25) — split out of `contact-point.test.ts`
|
|
3
|
+
* to keep each file under the house's 250-line cap. A secret value must reach the wire on write and
|
|
4
|
+
* never appear anywhere this resource returns or would persist.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { LIST_PATH, liveJson, props } from './contact-point-fixtures.ts';
|
|
9
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
10
|
+
import { grafanaOperations } from './resource.ts';
|
|
11
|
+
import { GrafanaSecretRefUnsetError, spec } from './contact-point.ts';
|
|
12
|
+
|
|
13
|
+
const ENV_VAR = 'GRAFANA_FAMILY_TEST_CP_TOKEN';
|
|
14
|
+
|
|
15
|
+
describe('secure settings references (S25)', () => {
|
|
16
|
+
test('create resolves the env var fresh and merges it into settings, never as a prop', async () => {
|
|
17
|
+
process.env[ENV_VAR] = 'xoxb-s3cret';
|
|
18
|
+
try {
|
|
19
|
+
const fake = fakeGrafana((method, url) =>
|
|
20
|
+
method === 'POST' && url.pathname === LIST_PATH
|
|
21
|
+
? Response.json(liveJson)
|
|
22
|
+
: fakeFailure(404, 'x'),
|
|
23
|
+
);
|
|
24
|
+
await Effect.runPromise(
|
|
25
|
+
spec
|
|
26
|
+
.create({ ...props, secureSettingsRefs: { url: ENV_VAR } })
|
|
27
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
28
|
+
);
|
|
29
|
+
expect(fake.bodies[0]).toMatchObject({
|
|
30
|
+
settings: { recipient: '#oncall', url: 'xoxb-s3cret' },
|
|
31
|
+
});
|
|
32
|
+
} finally {
|
|
33
|
+
delete process.env[ENV_VAR];
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the secret reaches the wire on create but never the RETURNED attributes — proven end to end', async () => {
|
|
38
|
+
// ⛔ THE POINT OF THIS FAMILY'S TESTS, TIGHTENED AFTER AN ADVERSARIAL REVIEW OF THIS PR: the
|
|
39
|
+
// earlier version only asserted `JSON.stringify(props)` never contained the secret — trivially
|
|
40
|
+
// true, since `props` never held it to begin with (it lives in `secureSettingsRefs`, an env
|
|
41
|
+
// var NAME). This drives a REAL `reconcile` (create, then the read-back `resource.ts` always
|
|
42
|
+
// does) through the fake and stringifies what it actually RETURNS — the shape that would be
|
|
43
|
+
// persisted to state — proving the secret never survives there, not just that it was never in
|
|
44
|
+
// an input we already knew was secret-free.
|
|
45
|
+
process.env[ENV_VAR] = 'xoxb-s3cret';
|
|
46
|
+
try {
|
|
47
|
+
let created = false;
|
|
48
|
+
const fake = fakeGrafana((method, url) => {
|
|
49
|
+
if (method === 'GET' && url.pathname === LIST_PATH) {
|
|
50
|
+
return created
|
|
51
|
+
? Response.json([
|
|
52
|
+
{ ...liveJson, settings: { recipient: '#oncall', url: 'RedactedValue' } },
|
|
53
|
+
])
|
|
54
|
+
: Response.json([]);
|
|
55
|
+
}
|
|
56
|
+
if (method === 'POST' && url.pathname === LIST_PATH) {
|
|
57
|
+
created = true;
|
|
58
|
+
return Response.json(liveJson);
|
|
59
|
+
}
|
|
60
|
+
return fakeFailure(500, 'unexpected request');
|
|
61
|
+
});
|
|
62
|
+
const declared = { ...props, secureSettingsRefs: { url: ENV_VAR } };
|
|
63
|
+
const attrs = await Effect.runPromise(
|
|
64
|
+
grafanaOperations(spec)
|
|
65
|
+
.reconcile(declared)
|
|
66
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
67
|
+
);
|
|
68
|
+
const createBody = fake.bodies[fake.seen.findIndex((s) => s.method === 'POST')];
|
|
69
|
+
// The wire genuinely carried it — the write would be useless otherwise.
|
|
70
|
+
expect(createBody).toMatchObject({ settings: { recipient: '#oncall', url: 'xoxb-s3cret' } });
|
|
71
|
+
// Nothing this resource returns or would persist does — the declaration, and the attributes
|
|
72
|
+
// `reconcile` hands back after reading the object right back through Grafana's own redaction.
|
|
73
|
+
expect(JSON.stringify(declared)).not.toContain('xoxb-s3cret');
|
|
74
|
+
expect(JSON.stringify(attrs)).not.toContain('xoxb-s3cret');
|
|
75
|
+
expect(attrs.settings).toEqual({ recipient: '#oncall', url: 'RedactedValue' });
|
|
76
|
+
} finally {
|
|
77
|
+
delete process.env[ENV_VAR];
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('create refuses with a typed error when the ref is unset — and sends nothing', async () => {
|
|
82
|
+
const fake = fakeGrafana(() => fakeFailure(500, 'should not be reached'));
|
|
83
|
+
const failure = await Effect.runPromise(
|
|
84
|
+
Effect.flip(
|
|
85
|
+
spec
|
|
86
|
+
.create({ ...props, secureSettingsRefs: { url: 'DEFINITELY_UNSET_VAR_XYZ' } })
|
|
87
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
expect(failure).toBeInstanceOf(GrafanaSecretRefUnsetError);
|
|
91
|
+
expect(fake.seen).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a redacted secure field on read never fails matches, even if props still names a stale value', async () => {
|
|
95
|
+
// ⛔ THE POINT: `settings.url` is declared here (a stale placeholder — the real value comes
|
|
96
|
+
// from `secureSettingsRefs` instead) specifically so a plain subset-match tolerance (live
|
|
97
|
+
// simply carrying an UNDECLARED extra key) is not what makes this pass — `nonSecretSettings`
|
|
98
|
+
// must actively exclude a key `secureSettingsRefs` names, even when `settings` also mentions
|
|
99
|
+
// it, or a redacted live value would show a permanent false `update`.
|
|
100
|
+
const declared = {
|
|
101
|
+
...props,
|
|
102
|
+
secureSettingsRefs: { url: 'IRRELEVANT' },
|
|
103
|
+
settings: { recipient: '#oncall', url: 'https://placeholder.invalid' },
|
|
104
|
+
};
|
|
105
|
+
const redactedLive = { ...liveJson, settings: { recipient: '#oncall', url: 'RedactedValue' } };
|
|
106
|
+
const fake = fakeGrafana((method, url) =>
|
|
107
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
108
|
+
? Response.json([redactedLive])
|
|
109
|
+
: fakeFailure(500, 'x'),
|
|
110
|
+
);
|
|
111
|
+
const live = await Effect.runPromise(
|
|
112
|
+
spec.fetchLive(declared).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
113
|
+
);
|
|
114
|
+
const attrs = live !== undefined ? spec.attributes(live, props) : undefined;
|
|
115
|
+
expect(attrs !== undefined && spec.matches(attrs, declared)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.ContactPoint` against a fake Grafana — the list-then-filter-by-uid read (no by-uid GET
|
|
3
|
+
* exists), the observe-before-write "adopt" posture, and subset-match tolerance. Mirrors
|
|
4
|
+
* `datasource.test.ts`. Secure-settings-ref tests: `contact-point-secrets.test.ts`. Provenance
|
|
5
|
+
* refusal tests (foreign and missing): `contact-point-provenance.test.ts` — split out to keep each
|
|
6
|
+
* 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 { ITEM_PATH, LIST_PATH, liveJson, props } from './contact-point-fixtures.ts';
|
|
11
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
12
|
+
import { grafanaOperations } from './resource.ts';
|
|
13
|
+
import { spec } from './contact-point.ts';
|
|
14
|
+
|
|
15
|
+
describe('spec.fetchLive', () => {
|
|
16
|
+
test('lists and filters by uid client-side — no by-uid GET route exists', async () => {
|
|
17
|
+
const other = { ...liveJson, name: 'Other', uid: 'other' };
|
|
18
|
+
const fake = fakeGrafana((method, url) =>
|
|
19
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
20
|
+
? Response.json([other, liveJson])
|
|
21
|
+
: fakeFailure(404, 'unexpected request'),
|
|
22
|
+
);
|
|
23
|
+
const live = await Effect.runPromise(
|
|
24
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
25
|
+
);
|
|
26
|
+
expect(live !== undefined && spec.attributes(live, props)).toEqual({
|
|
27
|
+
disableResolveMessage: false,
|
|
28
|
+
name: 'On-call Slack',
|
|
29
|
+
provenance: 'api',
|
|
30
|
+
settings: { recipient: '#oncall' },
|
|
31
|
+
type: 'slack',
|
|
32
|
+
uid: 'slack-oncall',
|
|
33
|
+
});
|
|
34
|
+
// ⛔ THE POINT: a GET never carries a body.
|
|
35
|
+
expect(fake.bodies[0]).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a uid absent from the list decodes to undefined, not an error', async () => {
|
|
39
|
+
const fake = fakeGrafana(() => Response.json([]));
|
|
40
|
+
const result = await Effect.runPromise(
|
|
41
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
42
|
+
);
|
|
43
|
+
expect(result).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a 403 propagates — never folded to absent', async () => {
|
|
47
|
+
const fake = fakeGrafana(() => fakeFailure(403, 'forbidden'));
|
|
48
|
+
const failure = await Effect.runPromise(
|
|
49
|
+
Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
|
|
50
|
+
);
|
|
51
|
+
expect(failure._tag).toBe('Forbidden');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('subset match — tolerates a default Grafana injects into settings', () => {
|
|
56
|
+
test('an unchanged declaration is a noop despite an extra live settings field it never declared', async () => {
|
|
57
|
+
const decorated = { ...liveJson, settings: { ...liveJson.settings, severity: 'critical' } };
|
|
58
|
+
const fake = fakeGrafana((method, url) =>
|
|
59
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
60
|
+
? Response.json([decorated])
|
|
61
|
+
: fakeFailure(500, 'reconcile should not write when content is unchanged'),
|
|
62
|
+
);
|
|
63
|
+
const after = await Effect.runPromise(
|
|
64
|
+
grafanaOperations(spec)
|
|
65
|
+
.reconcile(props)
|
|
66
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
67
|
+
);
|
|
68
|
+
expect(after.uid).toBe('slack-oncall');
|
|
69
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('reconcile — adopt semantics', () => {
|
|
74
|
+
test('declaring exactly what is live is a noop: no write is ever sent', async () => {
|
|
75
|
+
const fake = fakeGrafana((method, url) =>
|
|
76
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
77
|
+
? Response.json([liveJson])
|
|
78
|
+
: fakeFailure(500, 'reconcile should not write when nothing changed'),
|
|
79
|
+
);
|
|
80
|
+
const after = await Effect.runPromise(
|
|
81
|
+
grafanaOperations(spec)
|
|
82
|
+
.reconcile(props)
|
|
83
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
84
|
+
);
|
|
85
|
+
expect(after.uid).toBe('slack-oncall');
|
|
86
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('update and destroy — normal, api-provenance writes', () => {
|
|
91
|
+
test("an api-provenance contact point (this family's own writes) updates normally", async () => {
|
|
92
|
+
const fake = fakeGrafana((method, url) => {
|
|
93
|
+
if (method === 'GET' && url.pathname === LIST_PATH) return Response.json([liveJson]);
|
|
94
|
+
if (method === 'PUT' && url.pathname === ITEM_PATH) {
|
|
95
|
+
return Response.json({ ...liveJson, name: 'Renamed' });
|
|
96
|
+
}
|
|
97
|
+
return fakeFailure(500, 'unexpected request');
|
|
98
|
+
});
|
|
99
|
+
await Effect.runPromise(
|
|
100
|
+
grafanaOperations(spec)
|
|
101
|
+
.reconcile({ ...props, name: 'Renamed' })
|
|
102
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
103
|
+
);
|
|
104
|
+
expect(fake.seen.some((s) => s.method === 'PUT' && s.path === ITEM_PATH)).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('deletes a live, api-provenance contact point by uid', async () => {
|
|
108
|
+
const fake = fakeGrafana((method, url) => {
|
|
109
|
+
if (method === 'GET' && url.pathname === LIST_PATH) return Response.json([liveJson]);
|
|
110
|
+
if (method === 'DELETE' && url.pathname === ITEM_PATH) return Response.json({});
|
|
111
|
+
return fakeFailure(500, 'unexpected request');
|
|
112
|
+
});
|
|
113
|
+
await Effect.runPromise(
|
|
114
|
+
grafanaOperations(spec)
|
|
115
|
+
.destroy(props)
|
|
116
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
117
|
+
);
|
|
118
|
+
expect(fake.seen).toEqual([
|
|
119
|
+
{ method: 'GET', path: LIST_PATH },
|
|
120
|
+
{ method: 'DELETE', path: ITEM_PATH },
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.ContactPoint` — one Grafana alerting contact point, keyed by `uid`.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ NO GET-BY-UID ROUTE EXISTS — MEASURED against the SDK's generated operations (grep across
|
|
5
|
+
* `services/grafana.ts` for `routeGet.*[Cc]ontactpoint`: only `routeGetContactpoints` (list,
|
|
6
|
+
* with an optional `name` filter) and `routeGetContactpointsExport`). Unlike
|
|
7
|
+
* `Grafana.Datasource`/`Grafana.Folder`/`Grafana.Dashboard`, `fetchLive` cannot key its read
|
|
8
|
+
* directly on `uid`. Filtering server-side by `name` is NOT a substitute: `name` is a grouping
|
|
9
|
+
* label a declaration can rename (`uid` is the only field this resource keys on, and Grafana's
|
|
10
|
+
* own doc calls `name` "used as grouping key in the UI" — several contact points can share one),
|
|
11
|
+
* so narrowing the GET to the DECLARED name would miss a live object still filed under an OLDER
|
|
12
|
+
* name mid-rename, reporting it absent and attempting a duplicate `create`. `fetchLive` instead
|
|
13
|
+
* lists every contact point (`routeGetContactpoints({})`, no filter) and matches `uid`
|
|
14
|
+
* client-side — the same ambiguity `resource.ts`'s header and netbox's `soleMatch` name, resolved
|
|
15
|
+
* the same way: requiring the one field the API is actually keyed on.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ SECRET SETTINGS NEVER APPEAR AS A LITERAL PROP (S25) — same shape as `Grafana.Datasource`'s
|
|
18
|
+
* `secureJsonDataRefs` (`secret-refs.ts`, shared). Grafana does not use a separate top-level
|
|
19
|
+
* "secure settings" field for contact points the way it does for datasources; a secure key (a
|
|
20
|
+
* webhook URL with an embedded token, an API key, …) lives INSIDE `settings` on write, and comes
|
|
21
|
+
* back inside `settings` REDACTED on read — Grafana's own docs for the contact-points EXPORT
|
|
22
|
+
* route say as much explicitly ("Redacted settings will contain RedactedValue instead"; the
|
|
23
|
+
* plain, non-export GET this resource calls has no `decrypt` option at all, so it is redacted
|
|
24
|
+
* unconditionally). `secureSettingsRefs` therefore names which keys of `settings` are secret —
|
|
25
|
+
* value resolved from an env var NAME fresh inside `create`/`update`'s own effect (S24), merged
|
|
26
|
+
* into `settings` only at that point, never stored and never diffed (see `matches` below).
|
|
27
|
+
*
|
|
28
|
+
* ⛔ A FOREIGN-PROVENANCE CONTACT POINT REFUSES EVERY UPDATE AND DESTROY — `alerting-provenance.ts`,
|
|
29
|
+
* shared with `Grafana.MessageTemplate`/`Grafana.AlertRuleGroup`/`Grafana.NotificationPolicy`.
|
|
30
|
+
* See that file's header for the full reasoning and citations. `create` is never at risk: it only
|
|
31
|
+
* runs when `fetchLive` already found nothing with this `uid`.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ `X-Disable-Provenance` IS NEVER SET, BY DESIGN. Every create/update through this resource
|
|
34
|
+
* therefore leaves the contact point with `provenance: "api"` (Grafana's own default for an
|
|
35
|
+
* object this API just wrote) — which the docs describe as blocking that same object from being
|
|
36
|
+
* edited in the Grafana UI afterward. That is the intended outcome, not an oversight: an object
|
|
37
|
+
* this family declares is meant to be owned by the declaration, the same "one owner per resource"
|
|
38
|
+
* posture `provisioned.ts` establishes for folders/dashboards — handing it back to UI-editability
|
|
39
|
+
* by default would silently create the two-owner situation this whole file exists to prevent.
|
|
40
|
+
*/
|
|
41
|
+
import { Resource } from 'alchemy';
|
|
42
|
+
import * as Provider from 'alchemy/Provider';
|
|
43
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
44
|
+
import * as Effect from 'effect/Effect';
|
|
45
|
+
import {
|
|
46
|
+
type GrafanaProvisionedObjectError,
|
|
47
|
+
isForeignProvenance,
|
|
48
|
+
refuseIfForeignProvenance,
|
|
49
|
+
} from './alerting-provenance.ts';
|
|
50
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
51
|
+
import { GrafanaSecretRefUnsetError, resolveSecretRefs } from './secret-refs.ts';
|
|
52
|
+
import { declaredContentMatches } from './subset-match.ts';
|
|
53
|
+
|
|
54
|
+
export { GrafanaSecretRefUnsetError };
|
|
55
|
+
|
|
56
|
+
export interface ContactPointProps {
|
|
57
|
+
uid: string;
|
|
58
|
+
name: string;
|
|
59
|
+
/** Grafana integration id — `slack`, `webhook`, `email`, `pagerduty`, `opsgenie`, … */
|
|
60
|
+
type: string;
|
|
61
|
+
/** Non-secret settings — shape depends on `type`. Never put a secret VALUE here — declare it
|
|
62
|
+
* in `secureSettingsRefs` instead; see the file header. */
|
|
63
|
+
settings: Record<string, unknown>;
|
|
64
|
+
/** Secure field name (e.g. `url`, `token`, `password`) -> env var NAME holding its value. */
|
|
65
|
+
secureSettingsRefs?: Record<string, string>;
|
|
66
|
+
disableResolveMessage?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ContactPointAttributes {
|
|
70
|
+
uid: string;
|
|
71
|
+
name: string;
|
|
72
|
+
type: string;
|
|
73
|
+
/** As Grafana returns it — any secure key is a redaction placeholder, never the real value. */
|
|
74
|
+
settings: Record<string, unknown>;
|
|
75
|
+
disableResolveMessage: boolean;
|
|
76
|
+
/** `""`/`"api"` (this family may write); anything else, INCLUDING `undefined` (Grafana didn't
|
|
77
|
+
* report the field), refuses — see alerting-provenance.ts's "fails open" note. */
|
|
78
|
+
provenance: string | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface GrafanaContactPoint extends Resource<
|
|
82
|
+
'Grafana.ContactPoint',
|
|
83
|
+
ContactPointProps,
|
|
84
|
+
ContactPointAttributes,
|
|
85
|
+
never
|
|
86
|
+
> {}
|
|
87
|
+
|
|
88
|
+
export const GrafanaContactPoint = Resource<GrafanaContactPoint>('Grafana.ContactPoint');
|
|
89
|
+
|
|
90
|
+
/** `settings` minus every key `secureSettingsRefs` names — the only part `matches` ever compares
|
|
91
|
+
* (a secure key's live value is always a redaction placeholder, never comparable). */
|
|
92
|
+
const nonSecretSettings = (props: ContactPointProps): Record<string, unknown> => {
|
|
93
|
+
const refs = props.secureSettingsRefs;
|
|
94
|
+
if (refs === undefined) return props.settings;
|
|
95
|
+
const out: Record<string, unknown> = {};
|
|
96
|
+
for (const [key, value] of Object.entries(props.settings)) {
|
|
97
|
+
if (!(key in refs)) out[key] = value;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const resolveSettings = (props: ContactPointProps) =>
|
|
103
|
+
Effect.map(
|
|
104
|
+
resolveSecretRefs(props.secureSettingsRefs, (field) => `settings.${field}`),
|
|
105
|
+
(secrets) => (secrets === undefined ? props.settings : { ...props.settings, ...secrets }),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
export const spec: GrafanaSpec<
|
|
109
|
+
ContactPointProps,
|
|
110
|
+
grafana.EmbeddedContactPoint,
|
|
111
|
+
ContactPointAttributes,
|
|
112
|
+
| grafana.RouteGetContactpointsError
|
|
113
|
+
| grafana.RoutePostContactpointsError
|
|
114
|
+
| grafana.RoutePutContactpointError
|
|
115
|
+
| grafana.RouteDeleteContactpointsError
|
|
116
|
+
| GrafanaProvisionedObjectError
|
|
117
|
+
| GrafanaSecretRefUnsetError
|
|
118
|
+
> = {
|
|
119
|
+
attributes: (live) => ({
|
|
120
|
+
disableResolveMessage: live.disableResolveMessage ?? false,
|
|
121
|
+
name: live.name ?? '',
|
|
122
|
+
// ⛔ NEVER `?? ''` — an absent field must stay distinguishable from an explicit `""`
|
|
123
|
+
// (ProvenanceNone); see alerting-provenance.ts's "fails open" note.
|
|
124
|
+
provenance: live.provenance,
|
|
125
|
+
settings: (live.settings ?? {}) as Record<string, unknown>,
|
|
126
|
+
type: live.type,
|
|
127
|
+
uid: live.uid ?? '',
|
|
128
|
+
}),
|
|
129
|
+
create: (props) =>
|
|
130
|
+
Effect.flatMap(resolveSettings(props), (settings) =>
|
|
131
|
+
grafana.routePostContactpoints({
|
|
132
|
+
name: props.name,
|
|
133
|
+
settings,
|
|
134
|
+
type: props.type,
|
|
135
|
+
uid: props.uid,
|
|
136
|
+
...(props.disableResolveMessage === undefined
|
|
137
|
+
? {}
|
|
138
|
+
: { disableResolveMessage: props.disableResolveMessage }),
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
destroy: (props, live) =>
|
|
142
|
+
Effect.gen(function* () {
|
|
143
|
+
if (isForeignProvenance(live.provenance)) {
|
|
144
|
+
return yield* refuseIfForeignProvenance('Grafana.ContactPoint', props.uid, live.provenance);
|
|
145
|
+
}
|
|
146
|
+
yield* grafana.routeDeleteContactpoints({ UID: props.uid });
|
|
147
|
+
}),
|
|
148
|
+
fetchLive: (props) =>
|
|
149
|
+
grafana
|
|
150
|
+
.routeGetContactpoints({})
|
|
151
|
+
.pipe(Effect.map((points) => points.find((point) => point.uid === props.uid))),
|
|
152
|
+
matches: (attributes, props) =>
|
|
153
|
+
attributes.name === props.name &&
|
|
154
|
+
attributes.type === props.type &&
|
|
155
|
+
attributes.disableResolveMessage === (props.disableResolveMessage ?? false) &&
|
|
156
|
+
declaredContentMatches(nonSecretSettings(props), attributes.settings),
|
|
157
|
+
update: (props, live) =>
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
if (isForeignProvenance(live.provenance)) {
|
|
160
|
+
return yield* refuseIfForeignProvenance('Grafana.ContactPoint', props.uid, live.provenance);
|
|
161
|
+
}
|
|
162
|
+
const settings = yield* resolveSettings(props);
|
|
163
|
+
yield* grafana.routePutContactpoint({
|
|
164
|
+
UID: props.uid,
|
|
165
|
+
name: props.name,
|
|
166
|
+
settings,
|
|
167
|
+
type: props.type,
|
|
168
|
+
uid: props.uid,
|
|
169
|
+
...(props.disableResolveMessage === undefined
|
|
170
|
+
? {}
|
|
171
|
+
: { disableResolveMessage: props.disableResolveMessage }),
|
|
172
|
+
});
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const handlers = grafanaHandlers(spec);
|
|
177
|
+
|
|
178
|
+
export const GrafanaContactPointProvider = () =>
|
|
179
|
+
Provider.effect(GrafanaContactPoint, Effect.succeed(GrafanaContactPoint.Provider.of(handlers)));
|
|
@@ -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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
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
|
|
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) => ({
|