@homeflare/alchemy 0.32.0 → 0.33.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/dashboard-model.d.ts +77 -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/folder.d.ts +100 -0
- package/dist/grafana/folder.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +9 -8
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +158 -6
- package/dist/grafana/index.js.map +8 -4
- 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/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-folder-dashboard.md +145 -0
- package/docs/grafana.md +11 -10
- package/docs/upstream-conformance.md +9 -4
- package/package.json +1 -1
- package/src/grafana/dashboard-model.test.ts +111 -0
- package/src/grafana/dashboard-model.ts +111 -0
- package/src/grafana/dashboard.test.ts +232 -0
- package/src/grafana/dashboard.ts +161 -0
- package/src/grafana/folder.test.ts +212 -0
- package/src/grafana/folder.ts +161 -0
- package/src/grafana/index.ts +21 -8
- package/src/grafana/providers.ts +7 -1
- package/src/grafana/provisioned.ts +49 -0
- 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,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `normalizeModel`/`modelTitle`/`declaredContentMatches` in isolation — no SDK, no fake, no
|
|
3
|
+
* Effect. Proves the volatile fields (`id`, `version`, `iteration`) are stripped and `uid` is
|
|
4
|
+
* forced to the canonical value regardless of where — or whether — the source model carried one,
|
|
5
|
+
* and that `declaredContentMatches` tolerates Grafana's own schema-migration auto-decoration
|
|
6
|
+
* without hiding genuine content drift. All of which is what makes a pasted "Export as JSON" and
|
|
7
|
+
* a freshly-read live model compare equal in dashboard.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { declaredContentMatches, modelTitle, normalizeModel } from './dashboard-model.ts';
|
|
11
|
+
|
|
12
|
+
describe('normalizeModel', () => {
|
|
13
|
+
test('strips id, version and iteration', () => {
|
|
14
|
+
const model = { id: 42, iteration: 1700000000, title: 'Fleet', version: 7 };
|
|
15
|
+
expect(normalizeModel(model, 'fleet')).toEqual({ title: 'Fleet', uid: 'fleet' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('forces uid to the canonical value, overwriting a stale embedded one', () => {
|
|
19
|
+
const exported = { title: 'Fleet', uid: 'some-old-uid-from-an-export' };
|
|
20
|
+
expect(normalizeModel(exported, 'fleet')).toEqual({ title: 'Fleet', uid: 'fleet' });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('injects uid when the source model has none at all — a never-saved dashboard', () => {
|
|
24
|
+
const neverSaved = { title: 'Fleet' };
|
|
25
|
+
expect(normalizeModel(neverSaved, 'fleet')).toEqual({ title: 'Fleet', uid: 'fleet' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a declared model and a live model that only disagree on volatile fields normalize equal', () => {
|
|
29
|
+
const declared = { panels: [{ id: 1, title: 'CPU' }], title: 'Fleet' };
|
|
30
|
+
const live = {
|
|
31
|
+
id: 42,
|
|
32
|
+
iteration: 1700000000,
|
|
33
|
+
panels: [{ id: 1, title: 'CPU' }],
|
|
34
|
+
title: 'Fleet',
|
|
35
|
+
uid: 'fleet',
|
|
36
|
+
version: 9,
|
|
37
|
+
};
|
|
38
|
+
expect(normalizeModel(declared, 'fleet')).toEqual(normalizeModel(live, 'fleet'));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('preserves array order — panels and targets are position-significant, not a set', () => {
|
|
42
|
+
const model = { panels: [{ title: 'B' }, { title: 'A' }] };
|
|
43
|
+
expect(normalizeModel(model, 'x').panels).toEqual([{ title: 'B' }, { title: 'A' }]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('leaves an unrelated field genuinely different, so a real content change still shows', () => {
|
|
47
|
+
const declared = { title: 'Fleet v2' };
|
|
48
|
+
const live = { title: 'Fleet', uid: 'fleet' };
|
|
49
|
+
expect(normalizeModel(declared, 'fleet')).not.toEqual(normalizeModel(live, 'fleet'));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('modelTitle', () => {
|
|
54
|
+
test('reads the model title', () => {
|
|
55
|
+
expect(modelTitle({ title: 'Fleet' })).toBe('Fleet');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('answers empty for a model with no title', () => {
|
|
59
|
+
expect(modelTitle({})).toBe('');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('declaredContentMatches', () => {
|
|
64
|
+
// ⛔ THE REGRESSION THIS FUNCTION EXISTS TO FIX — found by an adversarial review of this PR.
|
|
65
|
+
// Grafana's own dashboard-save schema migration decorates a panel/target whose `datasource`
|
|
66
|
+
// is absent with an explicit `{type, uid}` reference. Before this function existed, a
|
|
67
|
+
// declared panel that never set `datasource` disagreed with what it read back FOREVER.
|
|
68
|
+
test('tolerates Grafana auto-decorating a panel/target with a datasource it never declared', () => {
|
|
69
|
+
const declared = {
|
|
70
|
+
panels: [{ targets: [{ refId: 'A' }], title: 'CPU' }],
|
|
71
|
+
title: 'Fleet',
|
|
72
|
+
};
|
|
73
|
+
const live = {
|
|
74
|
+
panels: [
|
|
75
|
+
{
|
|
76
|
+
datasource: { type: 'postgres', uid: 'teslamate' },
|
|
77
|
+
targets: [{ datasource: { type: 'postgres', uid: 'teslamate' }, refId: 'A' }],
|
|
78
|
+
title: 'CPU',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
title: 'Fleet',
|
|
82
|
+
};
|
|
83
|
+
expect(declaredContentMatches(declared, live)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('still catches a genuine change to a field the declaration DOES mention', () => {
|
|
87
|
+
const declared = { panels: [{ title: 'CPU' }] };
|
|
88
|
+
const live = { panels: [{ datasource: { uid: 'teslamate' }, title: 'Memory' }] };
|
|
89
|
+
expect(declaredContentMatches(declared, live)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('still fails when live is missing a field the declaration mentions', () => {
|
|
93
|
+
expect(declaredContentMatches({ title: 'Fleet' }, {})).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('still fails on an array length mismatch — panels are not padded away', () => {
|
|
97
|
+
const declared = { panels: [{ title: 'A' }, { title: 'B' }] };
|
|
98
|
+
const live = { panels: [{ title: 'A' }] };
|
|
99
|
+
expect(declaredContentMatches(declared, live)).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('ignores an extra top-level field live carries that the declaration never mentioned', () => {
|
|
103
|
+
expect(declaredContentMatches({ title: 'Fleet' }, { schemaVersion: 39, title: 'Fleet' })).toBe(
|
|
104
|
+
true,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('null (or undefined) on a declared leaf never mismatches, mirroring stripNullish', () => {
|
|
109
|
+
expect(declaredContentMatches({ description: null }, { description: 'set live' })).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions over a dashboard JSON model — no SDK call, no Effect. Split out of dashboard.ts
|
|
3
|
+
* so that file stays under the house's 250-line cap, and so `normalizeModel`'s no-change-is-noop
|
|
4
|
+
* behavior is exercised directly in dashboard-model.test.ts without a fake Grafana in the way.
|
|
5
|
+
*/
|
|
6
|
+
export type DashboardModel = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
/** Grafana injects or increments these on every save; a declared model never sets them meaningfully. */
|
|
9
|
+
const VOLATILE_FIELDS = ['id', 'version', 'iteration'] as const;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ★ NORMALIZES BOTH SIDES THE SAME WAY, so a no-change plan is a true noop
|
|
13
|
+
* (docs/grafana-folder-dashboard.md). Strips `id`/`version`/`iteration` (Grafana-managed, never
|
|
14
|
+
* declared content) and forces `uid` to this resource's OWN canonical value, wherever — or
|
|
15
|
+
* whether — the source object carried one. That second part is "uid placement": a dashboard
|
|
16
|
+
* pasted from Grafana's own "Export as JSON" always embeds the OLD live uid (or none, for a
|
|
17
|
+
* never-saved dashboard), while the live model this resource reads back always carries the
|
|
18
|
+
* CURRENT one; forcing both to `props.uid` before comparing is what makes those two shapes equal
|
|
19
|
+
* instead of permanently disagreeing on one field neither side is meant to declare independently
|
|
20
|
+
* (`DashboardProps.uid` — see dashboard.ts — is the single source of truth).
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ EVERY OTHER DECLARED FIELD COMPARES STRUCTURALLY, ARRAY ORDER INCLUDED — dashboard.ts's
|
|
23
|
+
* `matches` runs this output through THIS FILE's own `declaredContentMatches` (below), which
|
|
24
|
+
* never reorders an array. That is correct here: `panels` is position-significant (screen layout
|
|
25
|
+
* via its own `gridPos`, but the array order also drives Grafana's internal panel indexing and
|
|
26
|
+
* any `repeat` behavior) and each panel's `targets` is execution/legend order. Nothing in a
|
|
27
|
+
* dashboard model is a genuinely unordered SET the way UniFi's `trustedDhcpServerIpAddresses` is
|
|
28
|
+
* (unifi/network-form.ts's `sortedSet`) — this family has no field that needs one.
|
|
29
|
+
* ⚠️ `schemaVersion` IS DELIBERATELY **NOT** NORMALIZED — NOT MEASURED against a live instance (no
|
|
30
|
+
* live calls in this PR). Community reports say Grafana can rewrite an old dashboard's
|
|
31
|
+
* `schemaVersion` upward on load; IF that also happens on every plain save of an
|
|
32
|
+
* already-current-schema dashboard, every plan against it would show a false `update` forever —
|
|
33
|
+
* but silently normalizing it away on a guess would just as easily hide a genuine schema
|
|
34
|
+
* mismatch. Left comparing until measured: add it to `VOLATILE_FIELDS` with a dated, measured
|
|
35
|
+
* comment the day it is seen live, the same standard every other row in this file meets.
|
|
36
|
+
*/
|
|
37
|
+
export const normalizeModel = (model: DashboardModel, uid: string): DashboardModel => {
|
|
38
|
+
const normalized: DashboardModel = {};
|
|
39
|
+
for (const [key, value] of Object.entries(model)) {
|
|
40
|
+
if (key === 'uid') continue;
|
|
41
|
+
if ((VOLATILE_FIELDS as readonly string[]).includes(key)) continue;
|
|
42
|
+
normalized[key] = value;
|
|
43
|
+
}
|
|
44
|
+
normalized.uid = uid;
|
|
45
|
+
return normalized;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The model's own declared title — reporting only. It is never compared on its own; the whole
|
|
49
|
+
* normalized model is, in dashboard.ts's `matches`, so a title-only mismatch still shows `update`. */
|
|
50
|
+
export const modelTitle = (model: DashboardModel): string =>
|
|
51
|
+
typeof model.title === 'string' ? model.title : '';
|
|
52
|
+
|
|
53
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
54
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* ★ `declared` NEED ONLY BE A SUBSET OF `live` — an adversarial review of this PR (2026-09-24)
|
|
58
|
+
* found that plain structural equality made an ordinary, never-edited dashboard show `update`
|
|
59
|
+
* FOREVER: Grafana's own dashboard-save schema migration rewrites any panel or target whose
|
|
60
|
+
* `datasource` is absent into an explicit `{type, uid}` reference to the resolved default
|
|
61
|
+
* datasource — documented Grafana schema-migration behavior (not re-measured against a live
|
|
62
|
+
* instance in this PR — no live calls). A declared panel that never sets `datasource` (the
|
|
63
|
+
* common case: hand-authored JSON, or anything relying on the dashboard-level default) would,
|
|
64
|
+
* after the FIRST create, read back with that field populated; plain `deepEqual` (this file used
|
|
65
|
+
* `alchemy/Diff`'s before this fix) then disagreed with the declaration on every subsequent plan,
|
|
66
|
+
* `update` sent the undecorated model back, and Grafana redecorated it again on the next read —
|
|
67
|
+
* non-convergent.
|
|
68
|
+
*
|
|
69
|
+
* This function instead asks only "does every field the DECLARATION mentions, at any depth, still
|
|
70
|
+
* match what is live" — `live` may carry additional fields, at any depth, that `declared` never
|
|
71
|
+
* mentioned (the datasource auto-decoration above, or any future Grafana-injected default,
|
|
72
|
+
* `fieldConfig` defaults and `pluginVersion` included) without being compared at all. A field
|
|
73
|
+
* `declared` DOES mention that is missing, or different, in `live` still fails the match — real
|
|
74
|
+
* content drift is still caught exactly as before; only fields the declaration is silent about are
|
|
75
|
+
* now tolerated rather than compared.
|
|
76
|
+
*
|
|
77
|
+
* ⚠️ THE TRADE-OFF THIS ACCEPTS, DELIBERATELY: removing a field from the declaration (rather than
|
|
78
|
+
* changing its value) can no longer force Grafana to drop it. If a panel once declared an
|
|
79
|
+
* explicit `datasource` and a later declaration deletes that key entirely (rather than setting a
|
|
80
|
+
* different one), `live` keeps whatever the last write set — the declaration is silent about that
|
|
81
|
+
* key now, so nothing compares it, so no `update` ever fires to change it. This is the same
|
|
82
|
+
* "undeclared means don't care, never means force-to-default" convention already used everywhere
|
|
83
|
+
* else in this kit for a plain optional prop (e.g. `datasource.ts`'s `matches` never treats an
|
|
84
|
+
* undeclared `access` as "must be unset"); it now also applies inside the JSON model, not just at
|
|
85
|
+
* the props level. Declare an explicit replacement value to change a field; omission never clears
|
|
86
|
+
* one that was set by a previous declaration or by Grafana itself.
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ ARRAYS COMPARE BY LENGTH, THEN ELEMENT-WISE SUBSET — never by a looser "declared has fewer
|
|
89
|
+
* items" rule. `panels`/`targets` are position-significant (see `normalizeModel`'s header); an
|
|
90
|
+
* array Grafana pads or reorders on its own would still need normalizing here explicitly, the
|
|
91
|
+
* same as any other newly-discovered auto-decoration — this function does not paper over that.
|
|
92
|
+
* ⚠️ `null` ON EITHER SIDE OF A LEAF NEVER MISMATCHES (mirrors `alchemy/Diff`'s `deepEqual` with
|
|
93
|
+
* `stripNullish: true`, which this function replaces for the dashboard model specifically).
|
|
94
|
+
*/
|
|
95
|
+
export const declaredContentMatches = (declared: unknown, live: unknown): boolean => {
|
|
96
|
+
if (declared == null) return true;
|
|
97
|
+
if (Array.isArray(declared)) {
|
|
98
|
+
return (
|
|
99
|
+
Array.isArray(live) &&
|
|
100
|
+
declared.length === live.length &&
|
|
101
|
+
declared.every((item, index) => declaredContentMatches(item, live[index]))
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (isPlainObject(declared)) {
|
|
105
|
+
return (
|
|
106
|
+
isPlainObject(live) &&
|
|
107
|
+
Object.entries(declared).every(([key, value]) => declaredContentMatches(value, live[key]))
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return declared === live;
|
|
111
|
+
};
|
|
@@ -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)));
|