@homeflare/alchemy 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +47 -0
- package/dist/grafana/dashboard-model.d.ts.map +1 -0
- package/dist/grafana/dashboard.d.ts +98 -0
- package/dist/grafana/dashboard.d.ts.map +1 -0
- package/dist/grafana/datasource.d.ts +2 -9
- package/dist/grafana/datasource.d.ts.map +1 -1
- package/dist/grafana/folder.d.ts +100 -0
- package/dist/grafana/folder.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +16 -9
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +325 -16
- package/dist/grafana/index.js.map +15 -5
- 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/provisioned.d.ts +11 -0
- package/dist/grafana/provisioned.d.ts.map +1 -0
- 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/dist/proxmox/index.js +76 -45
- package/dist/proxmox/index.js.map +6 -6
- package/dist/proxmox/zfs-pool-form.d.ts +124 -0
- package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
- package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool.d.ts +27 -52
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/docs/grafana-alerting.md +145 -0
- package/docs/grafana-folder-dashboard.md +145 -0
- package/docs/grafana.md +12 -10
- package/docs/upstream-conformance.md +31 -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.test.ts +111 -0
- package/src/grafana/dashboard-model.ts +62 -0
- package/src/grafana/dashboard.test.ts +232 -0
- package/src/grafana/dashboard.ts +161 -0
- package/src/grafana/datasource.ts +5 -27
- package/src/grafana/folder.test.ts +212 -0
- package/src/grafana/folder.ts +161 -0
- package/src/grafana/index.ts +42 -9
- 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 +13 -1
- package/src/grafana/provisioned.ts +49 -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/proxmox/constraints-host-forms.test.ts +1 -1
- package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
- package/src/proxmox/zfs-pool-form.ts +236 -0
- package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
- package/src/proxmox/zfs-pool-wire.ts +132 -0
- package/src/proxmox/zfs-pool.ts +106 -76
- package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
- package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
- package/dist/proxmox/zfs-pool-write.d.ts +0 -128
- package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
- package/src/proxmox/zfs-pool-spec.ts +0 -76
- package/src/proxmox/zfs-pool-write.ts +0 -196
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.Dashboard` against a fake Grafana — the noop-despite-volatile-fields normalization, the
|
|
3
|
+
* provisioned-object refusal, and 412 propagating as a genuine failure rather than being caught or
|
|
4
|
+
* overwritten blindly. Mirrors `datasource.test.ts`/`folder.test.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { spec } from './dashboard.ts';
|
|
9
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
10
|
+
import { GrafanaProvisionedObjectError } from './provisioned.ts';
|
|
11
|
+
import { grafanaOperations } from './resource.ts';
|
|
12
|
+
|
|
13
|
+
const DASH_PATH = '/api/dashboards/uid/fleet-overview';
|
|
14
|
+
const CREATE_PATH = '/api/dashboards/db';
|
|
15
|
+
|
|
16
|
+
const model = { panels: [{ id: 1, title: 'CPU' }], title: 'Fleet Overview' };
|
|
17
|
+
|
|
18
|
+
const liveJson = {
|
|
19
|
+
dashboard: { ...model, id: 42, uid: 'fleet-overview', version: 3 },
|
|
20
|
+
meta: {
|
|
21
|
+
folderUid: 'infra',
|
|
22
|
+
provisioned: false,
|
|
23
|
+
provisionedExternalId: '',
|
|
24
|
+
url: '/d/fleet-overview',
|
|
25
|
+
version: 3,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const props = { dashboard: model, folderUid: 'infra', uid: 'fleet-overview' };
|
|
30
|
+
|
|
31
|
+
describe('spec.fetchLive', () => {
|
|
32
|
+
test('a live dashboard decodes and normalizes into typed attributes', async () => {
|
|
33
|
+
const fake = fakeGrafana((method, url) =>
|
|
34
|
+
method === 'GET' && url.pathname === DASH_PATH
|
|
35
|
+
? Response.json(liveJson)
|
|
36
|
+
: fakeFailure(404, 'not found'),
|
|
37
|
+
);
|
|
38
|
+
const live = await Effect.runPromise(
|
|
39
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
40
|
+
);
|
|
41
|
+
expect(live !== undefined && spec.attributes(live, props)).toEqual({
|
|
42
|
+
dashboard: { ...model, uid: 'fleet-overview' },
|
|
43
|
+
folderUid: 'infra',
|
|
44
|
+
provisioned: false,
|
|
45
|
+
provisionedExternalId: '',
|
|
46
|
+
title: 'Fleet Overview',
|
|
47
|
+
uid: 'fleet-overview',
|
|
48
|
+
url: '/d/fleet-overview',
|
|
49
|
+
version: 3,
|
|
50
|
+
});
|
|
51
|
+
expect(fake.bodies[0]).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a 404 folds to undefined', async () => {
|
|
55
|
+
const fake = fakeGrafana(() => fakeFailure(404, 'dashboard not found'));
|
|
56
|
+
const result = await Effect.runPromise(
|
|
57
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
58
|
+
);
|
|
59
|
+
expect(result).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('create', () => {
|
|
64
|
+
test('sends the normalized model and folderUid — no volatile fields, no extra keys', async () => {
|
|
65
|
+
const fake = fakeGrafana((method, url) =>
|
|
66
|
+
method === 'POST' && url.pathname === CREATE_PATH
|
|
67
|
+
? Response.json({
|
|
68
|
+
folderUid: 'infra',
|
|
69
|
+
id: 42,
|
|
70
|
+
status: 'success',
|
|
71
|
+
title: 'Fleet Overview',
|
|
72
|
+
uid: 'fleet-overview',
|
|
73
|
+
url: '/d/fleet-overview',
|
|
74
|
+
version: 1,
|
|
75
|
+
})
|
|
76
|
+
: fakeFailure(404, 'unexpected request'),
|
|
77
|
+
);
|
|
78
|
+
await Effect.runPromise(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
|
|
79
|
+
expect(fake.bodies[0]).toEqual({
|
|
80
|
+
dashboard: { ...model, uid: 'fleet-overview' },
|
|
81
|
+
folderUid: 'infra',
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('reconcile — noop despite volatile fields', () => {
|
|
87
|
+
test('a declared model matching live content is a noop even though id/version/iteration differ', async () => {
|
|
88
|
+
const fake = fakeGrafana((method, url) =>
|
|
89
|
+
method === 'GET' && url.pathname === DASH_PATH
|
|
90
|
+
? Response.json(liveJson)
|
|
91
|
+
: fakeFailure(500, 'reconcile should not write when content is unchanged'),
|
|
92
|
+
);
|
|
93
|
+
const after = await Effect.runPromise(
|
|
94
|
+
grafanaOperations(spec)
|
|
95
|
+
.reconcile(props)
|
|
96
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
97
|
+
);
|
|
98
|
+
expect(after.uid).toBe('fleet-overview');
|
|
99
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('update', () => {
|
|
104
|
+
// ★ ALL THREE TESTS GO THROUGH `grafanaOperations(spec).reconcile`, NOT `spec.update` DIRECTLY —
|
|
105
|
+
// `update` is optional on the shared `GrafanaSpec` type, and `reconcile` is what production
|
|
106
|
+
// code actually calls; it also exercises the SAME `fetchLive` → `matches` → `update` sequence a
|
|
107
|
+
// real plan/deploy does, rather than a hand-assembled `live` argument.
|
|
108
|
+
test('a content change sends the live version for optimistic concurrency, never overwrite', async () => {
|
|
109
|
+
const changed = { ...model, title: 'Fleet Overview v2' };
|
|
110
|
+
let saved = false;
|
|
111
|
+
const fake = fakeGrafana((method, url) => {
|
|
112
|
+
if (method === 'GET' && url.pathname === DASH_PATH) {
|
|
113
|
+
return Response.json(
|
|
114
|
+
saved
|
|
115
|
+
? {
|
|
116
|
+
dashboard: { ...changed, id: 42, uid: 'fleet-overview', version: 4 },
|
|
117
|
+
meta: { ...liveJson.meta, version: 4 },
|
|
118
|
+
}
|
|
119
|
+
: liveJson,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (method === 'POST' && url.pathname === CREATE_PATH) {
|
|
123
|
+
saved = true;
|
|
124
|
+
return Response.json({
|
|
125
|
+
folderUid: 'infra',
|
|
126
|
+
id: 42,
|
|
127
|
+
status: 'success',
|
|
128
|
+
title: 'Fleet Overview v2',
|
|
129
|
+
uid: 'fleet-overview',
|
|
130
|
+
url: '/d/fleet-overview',
|
|
131
|
+
version: 4,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return fakeFailure(500, 'unexpected request');
|
|
135
|
+
});
|
|
136
|
+
await Effect.runPromise(
|
|
137
|
+
grafanaOperations(spec)
|
|
138
|
+
.reconcile({ ...props, dashboard: changed })
|
|
139
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
140
|
+
);
|
|
141
|
+
const post = fake.seen.findIndex((s) => s.method === 'POST');
|
|
142
|
+
expect(fake.bodies[post]).toMatchObject({
|
|
143
|
+
dashboard: { ...changed, uid: 'fleet-overview', version: 3 },
|
|
144
|
+
});
|
|
145
|
+
// ⛔ `overwrite` IS NEVER SET — Grafana's own version check stays live on every update.
|
|
146
|
+
expect((fake.bodies[post] as { overwrite?: boolean }).overwrite).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('a 412 from a concurrent save propagates — never caught, never retried here', async () => {
|
|
150
|
+
const changed = { ...model, title: 'Fleet Overview v2' };
|
|
151
|
+
const fake = fakeGrafana((method, url) => {
|
|
152
|
+
if (method === 'GET' && url.pathname === DASH_PATH) return Response.json(liveJson);
|
|
153
|
+
if (method === 'POST' && url.pathname === CREATE_PATH) {
|
|
154
|
+
return fakeFailure(412, 'The dashboard has been changed by someone else');
|
|
155
|
+
}
|
|
156
|
+
return fakeFailure(500, 'unexpected request');
|
|
157
|
+
});
|
|
158
|
+
const failure = await Effect.runPromise(
|
|
159
|
+
Effect.flip(
|
|
160
|
+
grafanaOperations(spec)
|
|
161
|
+
.reconcile({ ...props, dashboard: changed })
|
|
162
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
expect(failure._tag).toBe('PreconditionFailed');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a provisioned dashboard refuses the write, naming its owning file', async () => {
|
|
169
|
+
const provisioned = {
|
|
170
|
+
...liveJson,
|
|
171
|
+
meta: { ...liveJson.meta, provisioned: true, provisionedExternalId: 'dashboards/fleet.json' },
|
|
172
|
+
};
|
|
173
|
+
const changed = { ...model, title: 'Fleet Overview v2' };
|
|
174
|
+
const fake = fakeGrafana((method, url) =>
|
|
175
|
+
method === 'GET' && url.pathname === DASH_PATH
|
|
176
|
+
? Response.json(provisioned)
|
|
177
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
178
|
+
);
|
|
179
|
+
const failure = await Effect.runPromise(
|
|
180
|
+
Effect.flip(
|
|
181
|
+
grafanaOperations(spec)
|
|
182
|
+
.reconcile({ ...props, dashboard: changed })
|
|
183
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
187
|
+
expect(failure.message).toContain('dashboards/fleet.json');
|
|
188
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('destroy', () => {
|
|
193
|
+
test('deletes a live, non-provisioned dashboard by uid', async () => {
|
|
194
|
+
const fake = fakeGrafana((method, url) => {
|
|
195
|
+
if (method === 'GET' && url.pathname === DASH_PATH) return Response.json(liveJson);
|
|
196
|
+
if (method === 'DELETE' && url.pathname === DASH_PATH) {
|
|
197
|
+
return Response.json({ message: 'ok', title: 'Fleet Overview', uid: 'fleet-overview' });
|
|
198
|
+
}
|
|
199
|
+
return fakeFailure(500, 'unexpected request');
|
|
200
|
+
});
|
|
201
|
+
await Effect.runPromise(
|
|
202
|
+
grafanaOperations(spec)
|
|
203
|
+
.destroy(props)
|
|
204
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
205
|
+
);
|
|
206
|
+
expect(fake.seen).toEqual([
|
|
207
|
+
{ method: 'GET', path: DASH_PATH },
|
|
208
|
+
{ method: 'DELETE', path: DASH_PATH },
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('a provisioned dashboard refuses destroy and sends no DELETE', async () => {
|
|
213
|
+
const provisioned = {
|
|
214
|
+
...liveJson,
|
|
215
|
+
meta: { ...liveJson.meta, provisioned: true, provisionedExternalId: 'dashboards/fleet.json' },
|
|
216
|
+
};
|
|
217
|
+
const fake = fakeGrafana((method, url) =>
|
|
218
|
+
method === 'GET' && url.pathname === DASH_PATH
|
|
219
|
+
? Response.json(provisioned)
|
|
220
|
+
: fakeFailure(500, 'no DELETE should be attempted'),
|
|
221
|
+
);
|
|
222
|
+
const failure = await Effect.runPromise(
|
|
223
|
+
Effect.flip(
|
|
224
|
+
grafanaOperations(spec)
|
|
225
|
+
.destroy(props)
|
|
226
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
230
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: DASH_PATH }]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.Dashboard` — one Grafana dashboard, keyed by `uid`. Same uid-required doctrine as
|
|
3
|
+
* `datasource.ts`/`folder.ts` (`getDashboard`/`postDashboard`/`deleteDashboard` are all UID-keyed
|
|
4
|
+
* on the wire — `postDashboard`'s own `uid` travels INSIDE the `dashboard` JSON body, not as a
|
|
5
|
+
* request field, because Grafana's classic dashboard-save API has never had one; see
|
|
6
|
+
* `dashboard-model.ts`'s `normalizeModel`, which is what puts it there).
|
|
7
|
+
*
|
|
8
|
+
* ★ THE DECLARED CONTENT IS THE DASHBOARD JSON MODEL — `DashboardProps.dashboard`, the same shape
|
|
9
|
+
* Grafana's own "Export as JSON" produces. `dashboard-model.ts`'s `normalizeModel` strips the
|
|
10
|
+
* volatile fields (`id`, `version`, `iteration`) and forces `uid` to this resource's own value
|
|
11
|
+
* before ANY comparison, so a plan against an unchanged dashboard is a true noop despite those
|
|
12
|
+
* fields moving on every live save — see that file for the full reasoning, including which
|
|
13
|
+
* fields (`panels`, `targets`) are array-ORDER-significant and compared as such.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ `matches` COMPARES "IS THE DECLARATION A SUBSET OF WHAT IS LIVE", NOT PLAIN EQUALITY —
|
|
16
|
+
* `dashboard-model.ts`'s `declaredContentMatches`, added after an adversarial review of this PR
|
|
17
|
+
* found plain `deepEqual` made an ordinary dashboard show `update` FOREVER: Grafana's own
|
|
18
|
+
* schema-migration on save decorates any panel/target whose `datasource` is absent with an
|
|
19
|
+
* explicit reference, so a declaration that never sets one would permanently disagree with what
|
|
20
|
+
* it reads back. See that function's own header for the full reasoning and the trade-off it
|
|
21
|
+
* accepts (an omitted field can no longer be used to CLEAR a previously-set one).
|
|
22
|
+
*
|
|
23
|
+
* ⛔ VERSION CONFLICTS (412) FAIL LOUDLY — NEVER OVERWRITTEN BLINDLY. `update` injects the
|
|
24
|
+
* `version` this resource just read (`live.meta.version`, from the SAME `fetchLive` call
|
|
25
|
+
* `resource.ts`'s `reconcile` already made before calling `update`) into the model it sends, and
|
|
26
|
+
* `overwrite` is never set. If Grafana's own version check then still fails — someone else saved
|
|
27
|
+
* in the gap between that read and this write — `postDashboard` answers `412` and
|
|
28
|
+
* `PreconditionFailed` is left IN the error union below, uncaught: the whole reconcile fails, the
|
|
29
|
+
* deploy stops, and nothing is written. The next run's `fetchLive` re-reads the new live version
|
|
30
|
+
* fresh, which is the "re-read" half of "re-read and fail loudly" — there is no separate retry
|
|
31
|
+
* loop in this file, because the next `plan`/`deploy` invocation already IS that re-read.
|
|
32
|
+
*
|
|
33
|
+
* ⛔ A PROVISIONED DASHBOARD (`meta.provisioned` — see provisioned.ts) REFUSES EVERY UPDATE AND
|
|
34
|
+
* DESTROY, naming `meta.provisionedExternalId` (the file) when Grafana reports one. `matches`
|
|
35
|
+
* still compares structurally regardless, so a mismatched declaration against a provisioned
|
|
36
|
+
* dashboard shows `update` in a plan rather than a silent, permanently-hidden noop.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ NO RETAIN DEFAULT, UNLIKE `Grafana.Folder`. Deleting one dashboard is a single-object blast
|
|
39
|
+
* radius — the same class `Grafana.Datasource` already accepts without a retain guard — not
|
|
40
|
+
* folder.ts's multi-object cascade (a folder's delete takes its dashboards AND their alert rules
|
|
41
|
+
* with it). See folder.ts's file header for the full contrast.
|
|
42
|
+
*/
|
|
43
|
+
import { Resource } from 'alchemy';
|
|
44
|
+
import * as Provider from 'alchemy/Provider';
|
|
45
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
46
|
+
import * as Effect from 'effect/Effect';
|
|
47
|
+
import {
|
|
48
|
+
type DashboardModel,
|
|
49
|
+
declaredContentMatches,
|
|
50
|
+
modelTitle,
|
|
51
|
+
normalizeModel,
|
|
52
|
+
} from './dashboard-model.ts';
|
|
53
|
+
import { type GrafanaProvisionedObjectError, refuseIfProvisioned } from './provisioned.ts';
|
|
54
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
55
|
+
|
|
56
|
+
export type { DashboardModel };
|
|
57
|
+
|
|
58
|
+
export interface DashboardProps {
|
|
59
|
+
uid: string;
|
|
60
|
+
/** Omit for the General/root folder. */
|
|
61
|
+
folderUid?: string;
|
|
62
|
+
/** The dashboard JSON model — see the file header. `id`/`version`/`iteration`/`uid` inside it
|
|
63
|
+
* are ignored; `uid` above is the single source of truth. */
|
|
64
|
+
dashboard: DashboardModel;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DashboardAttributes {
|
|
68
|
+
uid: string;
|
|
69
|
+
title: string;
|
|
70
|
+
version: number;
|
|
71
|
+
folderUid: string;
|
|
72
|
+
url: string;
|
|
73
|
+
/** True when Grafana reports this dashboard as file-provisioned (read-only — see provisioned.ts). */
|
|
74
|
+
provisioned: boolean;
|
|
75
|
+
provisionedExternalId: string;
|
|
76
|
+
/** The live model, normalized the same way `matches` compares it. */
|
|
77
|
+
dashboard: DashboardModel;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface GrafanaDashboard extends Resource<
|
|
81
|
+
'Grafana.Dashboard',
|
|
82
|
+
DashboardProps,
|
|
83
|
+
DashboardAttributes,
|
|
84
|
+
never
|
|
85
|
+
> {}
|
|
86
|
+
|
|
87
|
+
export const GrafanaDashboard = Resource<GrafanaDashboard>('Grafana.Dashboard');
|
|
88
|
+
|
|
89
|
+
const modelOf = (live: grafana.DashboardFullWithMeta): DashboardModel =>
|
|
90
|
+
live.dashboard !== null && typeof live.dashboard === 'object'
|
|
91
|
+
? (live.dashboard as DashboardModel)
|
|
92
|
+
: {};
|
|
93
|
+
|
|
94
|
+
export const spec: GrafanaSpec<
|
|
95
|
+
DashboardProps,
|
|
96
|
+
grafana.DashboardFullWithMeta,
|
|
97
|
+
DashboardAttributes,
|
|
98
|
+
| grafana.GetDashboardError
|
|
99
|
+
| grafana.PostDashboardError
|
|
100
|
+
| grafana.DeleteDashboardError
|
|
101
|
+
| GrafanaProvisionedObjectError
|
|
102
|
+
> = {
|
|
103
|
+
attributes: (live, props) => {
|
|
104
|
+
const model = normalizeModel(modelOf(live), props.uid);
|
|
105
|
+
return {
|
|
106
|
+
dashboard: model,
|
|
107
|
+
folderUid: live.meta?.folderUid ?? '',
|
|
108
|
+
provisioned: live.meta?.provisioned ?? false,
|
|
109
|
+
provisionedExternalId: live.meta?.provisionedExternalId ?? '',
|
|
110
|
+
title: modelTitle(model),
|
|
111
|
+
uid: props.uid,
|
|
112
|
+
url: live.meta?.url ?? '',
|
|
113
|
+
version: live.meta?.version ?? 0,
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
create: (props) =>
|
|
117
|
+
grafana.postDashboard({
|
|
118
|
+
dashboard: normalizeModel(props.dashboard, props.uid),
|
|
119
|
+
...(props.folderUid === undefined ? {} : { folderUid: props.folderUid }),
|
|
120
|
+
}),
|
|
121
|
+
destroy: (props, live) =>
|
|
122
|
+
Effect.gen(function* () {
|
|
123
|
+
if (live.meta?.provisioned) {
|
|
124
|
+
return yield* refuseIfProvisioned(
|
|
125
|
+
'Grafana.Dashboard',
|
|
126
|
+
props.uid,
|
|
127
|
+
live.meta.provisionedExternalId,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
yield* grafana.deleteDashboard({ uid: props.uid });
|
|
131
|
+
}),
|
|
132
|
+
fetchLive: (props) =>
|
|
133
|
+
grafana
|
|
134
|
+
.getDashboard({ uid: props.uid })
|
|
135
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
136
|
+
matches: (attributes, props) =>
|
|
137
|
+
(props.folderUid === undefined || attributes.folderUid === props.folderUid) &&
|
|
138
|
+
declaredContentMatches(normalizeModel(props.dashboard, props.uid), attributes.dashboard),
|
|
139
|
+
update: (props, live) =>
|
|
140
|
+
Effect.gen(function* () {
|
|
141
|
+
if (live.meta?.provisioned) {
|
|
142
|
+
return yield* refuseIfProvisioned(
|
|
143
|
+
'Grafana.Dashboard',
|
|
144
|
+
props.uid,
|
|
145
|
+
live.meta.provisionedExternalId,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return yield* grafana.postDashboard({
|
|
149
|
+
dashboard: {
|
|
150
|
+
...normalizeModel(props.dashboard, props.uid),
|
|
151
|
+
...(live.meta?.version === undefined ? {} : { version: live.meta.version }),
|
|
152
|
+
},
|
|
153
|
+
...(props.folderUid === undefined ? {} : { folderUid: props.folderUid }),
|
|
154
|
+
});
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const handlers = grafanaHandlers(spec);
|
|
159
|
+
|
|
160
|
+
export const GrafanaDashboardProvider = () =>
|
|
161
|
+
Provider.effect(GrafanaDashboard, Effect.succeed(GrafanaDashboard.Provider.of(handlers)));
|
|
@@ -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) => ({
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.Folder` against a fake Grafana — the observe-before-write "adopt" posture (S9/S10),
|
|
3
|
+
* the provisioned-object refusal (provisioned.ts) and the parentUid-is-create-only refusal, all
|
|
4
|
+
* through the REAL `@distilled.cloud/grafana` protocol. Mirrors `datasource.test.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
9
|
+
import { GrafanaFolderReparentError, spec } from './folder.ts';
|
|
10
|
+
import { GrafanaProvisionedObjectError } from './provisioned.ts';
|
|
11
|
+
import { grafanaOperations } from './resource.ts';
|
|
12
|
+
|
|
13
|
+
const FOLDER_PATH = '/api/folders/team-dashboards';
|
|
14
|
+
const CREATE_PATH = '/api/folders';
|
|
15
|
+
|
|
16
|
+
const liveJson = {
|
|
17
|
+
id: 3,
|
|
18
|
+
managedBy: '',
|
|
19
|
+
parentUid: '',
|
|
20
|
+
title: 'Team Dashboards',
|
|
21
|
+
uid: 'team-dashboards',
|
|
22
|
+
url: '/dashboards/f/team-dashboards',
|
|
23
|
+
version: 1,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const props = { title: 'Team Dashboards', uid: 'team-dashboards' };
|
|
27
|
+
|
|
28
|
+
describe('spec.fetchLive', () => {
|
|
29
|
+
test('a live folder decodes into typed attributes', async () => {
|
|
30
|
+
const fake = fakeGrafana((method, url) =>
|
|
31
|
+
method === 'GET' && url.pathname === FOLDER_PATH
|
|
32
|
+
? Response.json(liveJson)
|
|
33
|
+
: fakeFailure(404, 'not found'),
|
|
34
|
+
);
|
|
35
|
+
const live = await Effect.runPromise(
|
|
36
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
37
|
+
);
|
|
38
|
+
expect(live !== undefined && spec.attributes(live, props)).toEqual({
|
|
39
|
+
id: 3,
|
|
40
|
+
managedBy: '',
|
|
41
|
+
parentUid: '',
|
|
42
|
+
title: 'Team Dashboards',
|
|
43
|
+
uid: 'team-dashboards',
|
|
44
|
+
url: '/dashboards/f/team-dashboards',
|
|
45
|
+
version: 1,
|
|
46
|
+
});
|
|
47
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: FOLDER_PATH }]);
|
|
48
|
+
// ⛔ THE POINT: a GET never carries a body — see the shared memory's "distilled JSON-encodes
|
|
49
|
+
// unknown keys as a body even on a GET" trap.
|
|
50
|
+
expect(fake.bodies[0]).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a 404 folds to undefined', async () => {
|
|
54
|
+
const fake = fakeGrafana(() => fakeFailure(404, 'folder not found'));
|
|
55
|
+
const result = await Effect.runPromise(
|
|
56
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
57
|
+
);
|
|
58
|
+
expect(result).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a 403 propagates — never folded to absent', async () => {
|
|
62
|
+
const fake = fakeGrafana(() => fakeFailure(403, 'forbidden'));
|
|
63
|
+
const failure = await Effect.runPromise(
|
|
64
|
+
Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
|
|
65
|
+
);
|
|
66
|
+
expect(failure._tag).toBe('Forbidden');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('create', () => {
|
|
71
|
+
test('sends only declared fields — parentUid omitted when undeclared', async () => {
|
|
72
|
+
const fake = fakeGrafana((method, url) =>
|
|
73
|
+
method === 'POST' && url.pathname === CREATE_PATH
|
|
74
|
+
? Response.json(liveJson)
|
|
75
|
+
: fakeFailure(404, 'unexpected request'),
|
|
76
|
+
);
|
|
77
|
+
await Effect.runPromise(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
|
|
78
|
+
expect(fake.bodies[0]).toEqual({ title: 'Team Dashboards', uid: 'team-dashboards' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('sends description and parentUid when declared', async () => {
|
|
82
|
+
const fake = fakeGrafana(() => Response.json(liveJson));
|
|
83
|
+
await Effect.runPromise(
|
|
84
|
+
spec
|
|
85
|
+
.create({ ...props, description: 'squad dashboards', parentUid: 'engineering' })
|
|
86
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
87
|
+
);
|
|
88
|
+
expect(fake.bodies[0]).toMatchObject({
|
|
89
|
+
description: 'squad dashboards',
|
|
90
|
+
parentUid: 'engineering',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('reconcile — adopt semantics', () => {
|
|
96
|
+
test('declaring exactly what is live is a noop: no write is ever sent', async () => {
|
|
97
|
+
const fake = fakeGrafana((method, url) =>
|
|
98
|
+
method === 'GET' && url.pathname === FOLDER_PATH
|
|
99
|
+
? Response.json(liveJson)
|
|
100
|
+
: fakeFailure(500, 'reconcile should not write when nothing changed'),
|
|
101
|
+
);
|
|
102
|
+
const after = await Effect.runPromise(
|
|
103
|
+
grafanaOperations(spec)
|
|
104
|
+
.reconcile(props)
|
|
105
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
106
|
+
);
|
|
107
|
+
expect(after.uid).toBe('team-dashboards');
|
|
108
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('update', () => {
|
|
113
|
+
test('a title mismatch sends the live version for optimistic concurrency', async () => {
|
|
114
|
+
let updated = false;
|
|
115
|
+
const fake = fakeGrafana((method, url) => {
|
|
116
|
+
if (method === 'GET' && url.pathname === FOLDER_PATH) {
|
|
117
|
+
return Response.json(updated ? { ...liveJson, title: 'Renamed' } : liveJson);
|
|
118
|
+
}
|
|
119
|
+
if (method === 'PUT' && url.pathname === FOLDER_PATH) {
|
|
120
|
+
updated = true;
|
|
121
|
+
return Response.json({ ...liveJson, title: 'Renamed' });
|
|
122
|
+
}
|
|
123
|
+
return fakeFailure(500, 'unexpected request');
|
|
124
|
+
});
|
|
125
|
+
await Effect.runPromise(
|
|
126
|
+
grafanaOperations(spec)
|
|
127
|
+
.reconcile({ ...props, title: 'Renamed' })
|
|
128
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
129
|
+
);
|
|
130
|
+
const put = fake.seen.findIndex((s) => s.method === 'PUT');
|
|
131
|
+
expect(put).toBeGreaterThanOrEqual(0);
|
|
132
|
+
expect(fake.bodies[put]).toMatchObject({ title: 'Renamed', version: 1 });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ★ BOTH TESTS GO THROUGH `grafanaOperations(spec).reconcile`, NOT `spec.update` DIRECTLY —
|
|
136
|
+
// `update` is optional on the shared `GrafanaSpec` type, and `reconcile` is what production
|
|
137
|
+
// code actually calls; it also exercises the same `fetchLive` → `matches` → `update` sequence.
|
|
138
|
+
test('a parentUid mismatch refuses — updateFolder cannot reparent', async () => {
|
|
139
|
+
const fake = fakeGrafana((method, url) =>
|
|
140
|
+
method === 'GET' && url.pathname === FOLDER_PATH
|
|
141
|
+
? Response.json(liveJson)
|
|
142
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
143
|
+
);
|
|
144
|
+
const failure = await Effect.runPromise(
|
|
145
|
+
Effect.flip(
|
|
146
|
+
grafanaOperations(spec)
|
|
147
|
+
.reconcile({ ...props, parentUid: 'engineering' })
|
|
148
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
expect(failure).toBeInstanceOf(GrafanaFolderReparentError);
|
|
152
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a provisioned (managedBy set) folder refuses the write and sends nothing', async () => {
|
|
156
|
+
const provisioned = { ...liveJson, managedBy: 'repo' };
|
|
157
|
+
const fake = fakeGrafana((method, url) =>
|
|
158
|
+
method === 'GET' && url.pathname === FOLDER_PATH
|
|
159
|
+
? Response.json(provisioned)
|
|
160
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
161
|
+
);
|
|
162
|
+
const failure = await Effect.runPromise(
|
|
163
|
+
Effect.flip(
|
|
164
|
+
grafanaOperations(spec)
|
|
165
|
+
.reconcile({ ...props, title: 'Renamed' })
|
|
166
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
170
|
+
expect(failure.message).toContain('repo');
|
|
171
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('destroy', () => {
|
|
176
|
+
test('deletes a live, non-provisioned folder by uid', async () => {
|
|
177
|
+
const fake = fakeGrafana((method, url) => {
|
|
178
|
+
if (method === 'GET' && url.pathname === FOLDER_PATH) return Response.json(liveJson);
|
|
179
|
+
if (method === 'DELETE' && url.pathname === FOLDER_PATH) {
|
|
180
|
+
return Response.json({ id: 3, message: 'ok', title: 'Team Dashboards' });
|
|
181
|
+
}
|
|
182
|
+
return fakeFailure(500, 'unexpected request');
|
|
183
|
+
});
|
|
184
|
+
await Effect.runPromise(
|
|
185
|
+
grafanaOperations(spec)
|
|
186
|
+
.destroy(props)
|
|
187
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
188
|
+
);
|
|
189
|
+
expect(fake.seen).toEqual([
|
|
190
|
+
{ method: 'GET', path: FOLDER_PATH },
|
|
191
|
+
{ method: 'DELETE', path: FOLDER_PATH },
|
|
192
|
+
]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a provisioned folder refuses destroy and sends no DELETE', async () => {
|
|
196
|
+
const provisioned = { ...liveJson, managedBy: 'repo' };
|
|
197
|
+
const fake = fakeGrafana((method, url) =>
|
|
198
|
+
method === 'GET' && url.pathname === FOLDER_PATH
|
|
199
|
+
? Response.json(provisioned)
|
|
200
|
+
: fakeFailure(500, 'no DELETE should be attempted'),
|
|
201
|
+
);
|
|
202
|
+
const failure = await Effect.runPromise(
|
|
203
|
+
Effect.flip(
|
|
204
|
+
grafanaOperations(spec)
|
|
205
|
+
.destroy(props)
|
|
206
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
210
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: FOLDER_PATH }]);
|
|
211
|
+
});
|
|
212
|
+
});
|