@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,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
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.Folder` — one Grafana folder, keyed by `uid`. Mirrors `datasource.ts`'s shape and
|
|
3
|
+
* doctrine (uid required, not generated — same reasoning, `getFolderByUID`/`updateFolder`/
|
|
4
|
+
* `deleteFolder` are all UID-keyed and `getFolders` (list) has no per-uid filter).
|
|
5
|
+
*
|
|
6
|
+
* ⛔ `description` IS WRITE-ONLY — MEASURED against `@distilled.cloud/grafana`'s generated types
|
|
7
|
+
* (`Folder` in services/grafana.ts has no `description` field at all, though
|
|
8
|
+
* `CreateFolderRequest`/`UpdateFolderRequest` both accept one). Grafana's classic folder GET
|
|
9
|
+
* never returns it, not even as an is-it-set flag the way `DataSource.secureJsonFields` reports
|
|
10
|
+
* for a datasource's secure fields. So `description` is sent on every create/update that
|
|
11
|
+
* declares it but is NEVER part of `matches` — this resource cannot detect drift in a value it
|
|
12
|
+
* is never shown, the same treatment `datasource.ts` gives `secureJsonDataRefs`.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ NESTING (`parentUid`) IS CREATE-ONLY — MEASURED against the same generated types.
|
|
15
|
+
* `CreateFolderRequest.parentUid` exists; `UpdateFolderRequest` has no `parentUid` field at all
|
|
16
|
+
* (`folder_uid`, `description`, `overwrite`, `title`, `version` — reparenting is simply not a
|
|
17
|
+
* thing `PUT /folders/{uid}` can do). A declaration whose `parentUid` no longer matches the live
|
|
18
|
+
* folder therefore REFUSES with `GrafanaFolderReparentError` rather than either silently doing
|
|
19
|
+
* nothing (which would lie about the declaration taking effect) or deleting and recreating the
|
|
20
|
+
* folder under the new parent (which — see the ⛔ below — takes every dashboard and alert rule in
|
|
21
|
+
* it with it). Move a folder by hand in Grafana, then adjust the declaration to match.
|
|
22
|
+
*
|
|
23
|
+
* ⛔ DELETE DEFAULTS TO `retain` — DELETING A FOLDER DELETES ITS DASHBOARDS AND ALERT RULES IN
|
|
24
|
+
* GRAFANA, per `deleteFolder`'s own operation description ("along with all dashboards (and their
|
|
25
|
+
* alerts) stored in the folder... cannot be reverted... also deletes all the subfolders"). That
|
|
26
|
+
* is the same class of multi-object cascade `openbao/mount.ts` guards with
|
|
27
|
+
* `defaultRemovalPolicy: 'retain'` ("disabling a mount destroys every secret under it") — VERIFIED
|
|
28
|
+
* there against Alchemy's own `Apply.ts` (not re-verified here; same engine, same option). A
|
|
29
|
+
* stack that explicitly wants the cascade opts in with `.pipe(RemovalPolicy.destroy())` on the
|
|
30
|
+
* declared instance; `destroy` below still implements the real DELETE in full either way (S11).
|
|
31
|
+
* `Grafana.Dashboard` does NOT default to retain — deleting one dashboard is the same
|
|
32
|
+
* single-object blast radius `Grafana.Datasource` already accepts without a retain guard, not
|
|
33
|
+
* this family's multi-object cascade.
|
|
34
|
+
*
|
|
35
|
+
* ⛔ A PROVISIONED FOLDER (`managedBy` set — see provisioned.ts) REFUSES EVERY UPDATE AND DESTROY.
|
|
36
|
+
* `matches` still compares structurally either way, so a mismatched declaration against a
|
|
37
|
+
* provisioned folder shows `update` in a plan (never a silent, permanently-hidden noop) and then
|
|
38
|
+
* fails loudly, by design, when reconcile actually tries to write.
|
|
39
|
+
*/
|
|
40
|
+
import { Resource } from 'alchemy';
|
|
41
|
+
import * as Provider from 'alchemy/Provider';
|
|
42
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
43
|
+
import * as Data from 'effect/Data';
|
|
44
|
+
import * as Effect from 'effect/Effect';
|
|
45
|
+
import { type GrafanaProvisionedObjectError, refuseIfProvisioned } from './provisioned.ts';
|
|
46
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
47
|
+
|
|
48
|
+
export interface FolderProps {
|
|
49
|
+
uid: string;
|
|
50
|
+
title: string;
|
|
51
|
+
/** Write-only — see the file header. Sent on every create/update that declares it; never diffed. */
|
|
52
|
+
description?: string;
|
|
53
|
+
/** Parent folder UID, for nested folders. Create-only — see the file header. */
|
|
54
|
+
parentUid?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FolderAttributes {
|
|
58
|
+
uid: string;
|
|
59
|
+
id: number;
|
|
60
|
+
title: string;
|
|
61
|
+
parentUid: string;
|
|
62
|
+
/** Non-empty when Grafana reports this folder as externally managed (e.g. provisioning). */
|
|
63
|
+
managedBy: string;
|
|
64
|
+
url: string;
|
|
65
|
+
version: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface GrafanaFolder extends Resource<
|
|
69
|
+
'Grafana.Folder',
|
|
70
|
+
FolderProps,
|
|
71
|
+
FolderAttributes,
|
|
72
|
+
never
|
|
73
|
+
> {}
|
|
74
|
+
|
|
75
|
+
export const GrafanaFolder = Resource<GrafanaFolder>('Grafana.Folder', {
|
|
76
|
+
defaultRemovalPolicy: 'retain',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** A declared `parentUid` disagrees with the live folder's — `updateFolder` cannot reparent. */
|
|
80
|
+
export class GrafanaFolderReparentError extends Data.TaggedError('GrafanaFolderReparentError')<{
|
|
81
|
+
readonly message: string;
|
|
82
|
+
}> {}
|
|
83
|
+
|
|
84
|
+
export const spec: GrafanaSpec<
|
|
85
|
+
FolderProps,
|
|
86
|
+
grafana.Folder,
|
|
87
|
+
FolderAttributes,
|
|
88
|
+
| grafana.CreateFolderError
|
|
89
|
+
| grafana.GetFolderByUIDError
|
|
90
|
+
| grafana.UpdateFolderError
|
|
91
|
+
| grafana.DeleteFolderError
|
|
92
|
+
| GrafanaProvisionedObjectError
|
|
93
|
+
| GrafanaFolderReparentError
|
|
94
|
+
> = {
|
|
95
|
+
attributes: (live) => ({
|
|
96
|
+
id: live.id ?? 0,
|
|
97
|
+
managedBy: live.managedBy ?? '',
|
|
98
|
+
parentUid: live.parentUid ?? '',
|
|
99
|
+
title: live.title ?? '',
|
|
100
|
+
uid: live.uid ?? '',
|
|
101
|
+
url: live.url ?? '',
|
|
102
|
+
version: live.version ?? 0,
|
|
103
|
+
}),
|
|
104
|
+
create: (props) =>
|
|
105
|
+
grafana.createFolder({
|
|
106
|
+
uid: props.uid,
|
|
107
|
+
title: props.title,
|
|
108
|
+
...(props.description === undefined ? {} : { description: props.description }),
|
|
109
|
+
...(props.parentUid === undefined ? {} : { parentUid: props.parentUid }),
|
|
110
|
+
}),
|
|
111
|
+
destroy: (props, live) =>
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
if (live.managedBy) {
|
|
114
|
+
return yield* refuseIfProvisioned('Grafana.Folder', props.uid, live.managedBy);
|
|
115
|
+
}
|
|
116
|
+
yield* grafana.deleteFolder({ folder_uid: props.uid });
|
|
117
|
+
}),
|
|
118
|
+
fetchLive: (props) =>
|
|
119
|
+
grafana
|
|
120
|
+
.getFolderByUID({ folder_uid: props.uid })
|
|
121
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
122
|
+
matches: (attributes, props) =>
|
|
123
|
+
attributes.title === props.title &&
|
|
124
|
+
(props.parentUid === undefined || attributes.parentUid === props.parentUid),
|
|
125
|
+
update: (props, live) =>
|
|
126
|
+
Effect.gen(function* () {
|
|
127
|
+
if (live.managedBy) {
|
|
128
|
+
return yield* refuseIfProvisioned('Grafana.Folder', props.uid, live.managedBy);
|
|
129
|
+
}
|
|
130
|
+
if (props.parentUid !== undefined && props.parentUid !== (live.parentUid ?? undefined)) {
|
|
131
|
+
return yield* Effect.fail(
|
|
132
|
+
new GrafanaFolderReparentError({
|
|
133
|
+
message:
|
|
134
|
+
`Grafana.Folder '${props.uid}' declares parentUid '${props.parentUid}' but is ` +
|
|
135
|
+
`live under '${live.parentUid ?? '(root)'}'. PUT /folders/{uid} cannot reparent a ` +
|
|
136
|
+
`folder — move it by hand in Grafana, then update the declaration to match.`,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
// ⚠️ UNLIKE DASHBOARD.TS's 412, THIS HAS NO VERIFIED CONFLICT SIGNAL. `version` is sent
|
|
141
|
+
// optimistically, but the SDK's own doc comment on `UpdateFolderRequest.version` says
|
|
142
|
+
// "only used by the legacy folder implementation" — on Grafana's newer unified-storage
|
|
143
|
+
// folder backend it is plausibly ignored outright, meaning two concurrent title/description
|
|
144
|
+
// writes could silently last-write-win with no error, unlike a dashboard save. NOT
|
|
145
|
+
// MEASURED against a live instance either way (no live calls in this PR; flagged by an
|
|
146
|
+
// adversarial review) — sending it is harmless (a no-op field on a backend that ignores it)
|
|
147
|
+
// and correct on one that still honors it, so it stays, but do not read its PRESENCE here
|
|
148
|
+
// as proof this resource detects folder update conflicts the way dashboard.ts does.
|
|
149
|
+
return yield* grafana.updateFolder({
|
|
150
|
+
folder_uid: props.uid,
|
|
151
|
+
title: props.title,
|
|
152
|
+
...(props.description === undefined ? {} : { description: props.description }),
|
|
153
|
+
...(live.version === undefined ? {} : { version: live.version }),
|
|
154
|
+
});
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const handlers = grafanaHandlers(spec);
|
|
159
|
+
|
|
160
|
+
export const GrafanaFolderProvider = () =>
|
|
161
|
+
Provider.effect(GrafanaFolder, Effect.succeed(GrafanaFolder.Provider.of(handlers)));
|
package/src/grafana/index.ts
CHANGED
|
@@ -2,15 +2,13 @@
|
|
|
2
2
|
* Grafana providers for Alchemy.
|
|
3
3
|
*
|
|
4
4
|
* ⛔ THIS BARREL IS THE PUBLIC API, AND IT IS DELIBERATELY SMALLER THAN THE DIRECTORY. These are
|
|
5
|
-
* the symbols a real stack consumes; the rest of the files (resource.ts's engine,
|
|
6
|
-
* are internals a provider needs but a consumer should not
|
|
5
|
+
* the symbols a real stack consumes; the rest of the files (resource.ts's engine, provisioned.ts,
|
|
6
|
+
* dashboard-model.ts, fake-grafana.ts) are internals a provider needs but a consumer should not
|
|
7
|
+
* depend on.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* docs/upstream-conformance.md recorded is fixed. Building `Grafana.Folder`, `Grafana.Dashboard`,
|
|
12
|
-
* `Grafana.AlertRule` etc. on top of them is follow-up work this PR deliberately does not do —
|
|
13
|
-
* see docs/grafana.md for what changed and what is still open.
|
|
9
|
+
* `Grafana.Datasource`, `Grafana.Folder` and `Grafana.Dashboard` ship here. `Grafana.AlertRule`
|
|
10
|
+
* and friends are still follow-up work on the same now-available SDK operations — see
|
|
11
|
+
* docs/grafana.md and docs/grafana-folder-dashboard.md for what shipped and what is still open.
|
|
14
12
|
*/
|
|
15
13
|
export {
|
|
16
14
|
type DatasourceAttributes,
|
|
@@ -19,5 +17,20 @@ export {
|
|
|
19
17
|
GrafanaDatasourceProvider,
|
|
20
18
|
GrafanaSecretRefUnsetError,
|
|
21
19
|
} from './datasource.ts';
|
|
20
|
+
export {
|
|
21
|
+
type DashboardAttributes,
|
|
22
|
+
type DashboardModel,
|
|
23
|
+
type DashboardProps,
|
|
24
|
+
GrafanaDashboard,
|
|
25
|
+
GrafanaDashboardProvider,
|
|
26
|
+
} from './dashboard.ts';
|
|
27
|
+
export {
|
|
28
|
+
type FolderAttributes,
|
|
29
|
+
type FolderProps,
|
|
30
|
+
GrafanaFolder,
|
|
31
|
+
GrafanaFolderProvider,
|
|
32
|
+
GrafanaFolderReparentError,
|
|
33
|
+
} from './folder.ts';
|
|
34
|
+
export { GrafanaProvisionedObjectError } from './provisioned.ts';
|
|
22
35
|
export { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
|
|
23
36
|
export { grafanaProviders } from './providers.ts';
|
package/src/grafana/providers.ts
CHANGED
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as Layer from 'effect/Layer';
|
|
15
15
|
import { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
|
|
16
|
+
import { GrafanaDashboardProvider } from './dashboard.ts';
|
|
16
17
|
import { GrafanaDatasourceProvider } from './datasource.ts';
|
|
18
|
+
import { GrafanaFolderProvider } from './folder.ts';
|
|
17
19
|
|
|
18
20
|
export const grafanaProviders = (target: GrafanaTarget) =>
|
|
19
|
-
|
|
21
|
+
Layer.mergeAll(
|
|
22
|
+
GrafanaDatasourceProvider(),
|
|
23
|
+
GrafanaFolderProvider(),
|
|
24
|
+
GrafanaDashboardProvider(),
|
|
25
|
+
).pipe(Layer.provide(grafanaCredentials(target)));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared refusal for a file-provisioned Grafana object — `Grafana.Folder` and
|
|
3
|
+
* `Grafana.Dashboard` both carry a live signal Grafana itself treats as read-only through the
|
|
4
|
+
* classic write API this family calls: `Folder.managedBy` (non-empty when something other than
|
|
5
|
+
* this API manages the folder — provisioning is the case this house cares about, but the field
|
|
6
|
+
* itself is Grafana's general "who owns this" marker) and `DashboardMeta.provisioned` (paired
|
|
7
|
+
* with `provisionedExternalId`, the file path). A declaration must never attempt a write against
|
|
8
|
+
* either — see docs/grafana-folder-dashboard.md's "provisioned objects" section.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ THE EXACT `managedBy` VALUES ARE INFERRED FROM THE SDK's SCHEMA, NOT MEASURED. No live
|
|
11
|
+
* instance in this house currently has a file-provisioned FOLDER to read (docs/grafana.md:
|
|
12
|
+
* `teslamate-grafana` has none at all; the mini's Grafana provisions dashboards, not folders) and
|
|
13
|
+
* this PR makes no live calls. `Folder.managedBy` and `DashboardMeta.provisioned` are exactly
|
|
14
|
+
* the fields the OpenAPI schema types for this purpose — treat any non-empty `managedBy` as
|
|
15
|
+
* provisioned rather than special-casing specific string values that were never observed live.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ ADOPTING A PROVISIONED OBJECT IS STILL FINE. `read`/`reconcile`'s no-write "already converged"
|
|
18
|
+
* path (resource.ts) never calls `create`/`update`/`destroy` at all when the declaration already
|
|
19
|
+
* matches what is live — only an actual attempted WRITE against a provisioned object is refused,
|
|
20
|
+
* by the two callers of this function inside folder.ts/dashboard.ts's own `update`/`destroy`.
|
|
21
|
+
*/
|
|
22
|
+
import * as Data from 'effect/Data';
|
|
23
|
+
import * as Effect from 'effect/Effect';
|
|
24
|
+
|
|
25
|
+
export class GrafanaProvisionedObjectError extends Data.TaggedError(
|
|
26
|
+
'GrafanaProvisionedObjectError',
|
|
27
|
+
)<{
|
|
28
|
+
readonly message: string;
|
|
29
|
+
}> {}
|
|
30
|
+
|
|
31
|
+
export const refuseIfProvisioned = (
|
|
32
|
+
kind: 'Grafana.Folder' | 'Grafana.Dashboard',
|
|
33
|
+
uid: string,
|
|
34
|
+
owner: string | undefined,
|
|
35
|
+
): Effect.Effect<never, GrafanaProvisionedObjectError> => {
|
|
36
|
+
const ownerClause =
|
|
37
|
+
owner === undefined || owner === ''
|
|
38
|
+
? `(this Grafana instance did not report an owning file)`
|
|
39
|
+
: `by ${owner}`;
|
|
40
|
+
return Effect.fail(
|
|
41
|
+
new GrafanaProvisionedObjectError({
|
|
42
|
+
message:
|
|
43
|
+
`${kind} '${uid}' is file-provisioned ${ownerClause} and read-only through the classic ` +
|
|
44
|
+
`write API this family calls. Edit the provisioning file instead — declaring this ` +
|
|
45
|
+
`object read-only (a declaration matching what is live) is safe and will never reach ` +
|
|
46
|
+
`this refusal.`,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -13,7 +13,7 @@ import { constraintsFor, formViolations } from './constraint-guard.ts';
|
|
|
13
13
|
import type { PbsTarget, PveTarget } from './credentials.ts';
|
|
14
14
|
import { createBody as ifaceCreateForm } from './node-network-form.ts';
|
|
15
15
|
import { resolveGroups, targetForm } from './pbs-notification-target-form.ts';
|
|
16
|
-
import { createForm as zfsCreateForm } from './zfs-pool-
|
|
16
|
+
import { createForm as zfsCreateForm } from './zfs-pool-form.ts';
|
|
17
17
|
|
|
18
18
|
const TARGET: PveTarget = { members: ['pve.test:8006'], mount: 'pve-test', scheme: 'pve' };
|
|
19
19
|
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
@@ -138,4 +138,30 @@ describe('adopting an adopt-only ZfsPool declaration', () => {
|
|
|
138
138
|
const r: ZfsRaidLevel = 'stripe';
|
|
139
139
|
expect(String(r)).toBe('stripe');
|
|
140
140
|
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* ⛔ FOUND BY ADVERSARIAL REVIEW, 2026-09-24: a genuinely FIRST-EVER declaration never reaches
|
|
144
|
+
* `diff` at all (upstream `Plan.ts` routes `oldState === undefined` straight to `create`), so
|
|
145
|
+
* the vendor-constraint check `diff` runs had never once protected a brand-new pool's own
|
|
146
|
+
* create -- only an ADOPT's later plans. `createPool` (zfs-pool-form.ts) now re-checks before
|
|
147
|
+
* its own POST, the same pattern `ceph-pool.ts`'s hand-written `reconcile` already uses.
|
|
148
|
+
*/
|
|
149
|
+
test('a malformed ashift on a brand-new (non-adopt) declaration is refused before any write', async () => {
|
|
150
|
+
const fake = cluster(new Set());
|
|
151
|
+
await withoutBao(async () => {
|
|
152
|
+
const engine = engineFor(fake);
|
|
153
|
+
const bad = () =>
|
|
154
|
+
ProxmoxZfsPool('bad', {
|
|
155
|
+
// ⛔ PVE's own bound is 9-16 (generated/constraints/pve-nodes-disks.ts) -- 99 is refused.
|
|
156
|
+
ashift: 99,
|
|
157
|
+
devices: ['/dev/disk/by-id/fake-0'],
|
|
158
|
+
name: 'newpool',
|
|
159
|
+
node: 'node-b',
|
|
160
|
+
raidlevel: 'single',
|
|
161
|
+
target: FAKE_TARGET,
|
|
162
|
+
});
|
|
163
|
+
await expect(engine.deploy(bad())).rejects.toBeDefined();
|
|
164
|
+
});
|
|
165
|
+
expect(fake.writes()).toEqual([]);
|
|
166
|
+
});
|
|
141
167
|
});
|