@homeflare/alchemy 0.31.2 → 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.
Files changed (184) hide show
  1. package/README.md +39 -3
  2. package/dist/grafana/dashboard-model.d.ts +77 -0
  3. package/dist/grafana/dashboard-model.d.ts.map +1 -0
  4. package/dist/grafana/dashboard.d.ts +98 -0
  5. package/dist/grafana/dashboard.d.ts.map +1 -0
  6. package/dist/grafana/folder.d.ts +100 -0
  7. package/dist/grafana/folder.d.ts.map +1 -0
  8. package/dist/grafana/index.d.ts +9 -8
  9. package/dist/grafana/index.d.ts.map +1 -1
  10. package/dist/grafana/index.js +158 -6
  11. package/dist/grafana/index.js.map +8 -4
  12. package/dist/grafana/providers.d.ts +1 -1
  13. package/dist/grafana/providers.d.ts.map +1 -1
  14. package/dist/grafana/provisioned.d.ts +11 -0
  15. package/dist/grafana/provisioned.d.ts.map +1 -0
  16. package/dist/linux/container-form.d.ts +129 -0
  17. package/dist/linux/container-form.d.ts.map +1 -0
  18. package/dist/linux/container-generator.d.ts +74 -0
  19. package/dist/linux/container-generator.d.ts.map +1 -0
  20. package/dist/linux/container-handlers.d.ts +15 -0
  21. package/dist/linux/container-handlers.d.ts.map +1 -0
  22. package/dist/linux/container-lifecycle.d.ts +37 -0
  23. package/dist/linux/container-lifecycle.d.ts.map +1 -0
  24. package/dist/linux/container-preflight.d.ts +34 -0
  25. package/dist/linux/container-preflight.d.ts.map +1 -0
  26. package/dist/linux/container-secrets.d.ts +37 -0
  27. package/dist/linux/container-secrets.d.ts.map +1 -0
  28. package/dist/linux/container-settle.d.ts +34 -0
  29. package/dist/linux/container-settle.d.ts.map +1 -0
  30. package/dist/linux/container.d.ts +30 -0
  31. package/dist/linux/container.d.ts.map +1 -0
  32. package/dist/linux/fake-quadlet.d.ts +25 -0
  33. package/dist/linux/fake-quadlet.d.ts.map +1 -0
  34. package/dist/linux/index.d.ts +6 -3
  35. package/dist/linux/index.d.ts.map +1 -1
  36. package/dist/linux/index.js +641 -268
  37. package/dist/linux/index.js.map +15 -7
  38. package/dist/linux/providers.d.ts +1 -1
  39. package/dist/linux/providers.d.ts.map +1 -1
  40. package/dist/linux/systemctl.d.ts +7 -1
  41. package/dist/linux/systemctl.d.ts.map +1 -1
  42. package/dist/opnsense/alias-form.d.ts +29 -0
  43. package/dist/opnsense/alias-form.d.ts.map +1 -0
  44. package/dist/opnsense/alias.d.ts +81 -0
  45. package/dist/opnsense/alias.d.ts.map +1 -0
  46. package/dist/opnsense/category-form.d.ts +10 -0
  47. package/dist/opnsense/category-form.d.ts.map +1 -0
  48. package/dist/opnsense/category.d.ts +58 -0
  49. package/dist/opnsense/category.d.ts.map +1 -0
  50. package/dist/opnsense/credentials.d.ts +36 -0
  51. package/dist/opnsense/credentials.d.ts.map +1 -0
  52. package/dist/opnsense/fake-opnsense.d.ts +51 -0
  53. package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
  54. package/dist/opnsense/group-form.d.ts +11 -0
  55. package/dist/opnsense/group-form.d.ts.map +1 -0
  56. package/dist/opnsense/group.d.ts +62 -0
  57. package/dist/opnsense/group.d.ts.map +1 -0
  58. package/dist/opnsense/index.d.ts +16 -0
  59. package/dist/opnsense/index.d.ts.map +1 -0
  60. package/dist/opnsense/index.js +211 -0
  61. package/dist/opnsense/index.js.map +20 -0
  62. package/dist/opnsense/policy.d.ts +20 -0
  63. package/dist/opnsense/policy.d.ts.map +1 -0
  64. package/dist/opnsense/providers.d.ts +13 -0
  65. package/dist/opnsense/providers.d.ts.map +1 -0
  66. package/dist/opnsense/resource.d.ts +74 -0
  67. package/dist/opnsense/resource.d.ts.map +1 -0
  68. package/dist/opnsense/wire.d.ts +39 -0
  69. package/dist/opnsense/wire.d.ts.map +1 -0
  70. package/dist/proxmox/index.js +170 -80
  71. package/dist/proxmox/index.js.map +9 -7
  72. package/dist/proxmox/storage-form.d.ts +54 -0
  73. package/dist/proxmox/storage-form.d.ts.map +1 -0
  74. package/dist/proxmox/storage-wire.d.ts +56 -0
  75. package/dist/proxmox/storage-wire.d.ts.map +1 -0
  76. package/dist/proxmox/storage.d.ts +55 -60
  77. package/dist/proxmox/storage.d.ts.map +1 -1
  78. package/dist/proxmox/zfs-pool-form.d.ts +124 -0
  79. package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
  80. package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
  81. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
  82. package/dist/proxmox/zfs-pool.d.ts +27 -52
  83. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  84. package/dist/unifi/fake-unifi.d.ts +48 -0
  85. package/dist/unifi/fake-unifi.d.ts.map +1 -0
  86. package/dist/unifi/firewall-zone-form.d.ts +40 -0
  87. package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
  88. package/dist/unifi/firewall-zone.d.ts +50 -0
  89. package/dist/unifi/firewall-zone.d.ts.map +1 -0
  90. package/dist/unifi/index.d.ts +13 -0
  91. package/dist/unifi/index.d.ts.map +1 -0
  92. package/dist/unifi/index.js +190 -0
  93. package/dist/unifi/index.js.map +16 -0
  94. package/dist/unifi/network-form.d.ts +67 -0
  95. package/dist/unifi/network-form.d.ts.map +1 -0
  96. package/dist/unifi/network.d.ts +67 -0
  97. package/dist/unifi/network.d.ts.map +1 -0
  98. package/dist/unifi/policy.d.ts +20 -0
  99. package/dist/unifi/policy.d.ts.map +1 -0
  100. package/dist/unifi/providers.d.ts +13 -0
  101. package/dist/unifi/providers.d.ts.map +1 -0
  102. package/dist/unifi/resource.d.ts +64 -0
  103. package/dist/unifi/resource.d.ts.map +1 -0
  104. package/docs/grafana-folder-dashboard.md +145 -0
  105. package/docs/grafana.md +11 -10
  106. package/docs/opnsense.md +146 -0
  107. package/docs/provider-standard.md +1 -1
  108. package/docs/quadlet-container.md +132 -0
  109. package/docs/unifi.md +93 -0
  110. package/docs/upstream-conformance.md +9 -4
  111. package/package.json +12 -2
  112. package/src/grafana/dashboard-model.test.ts +111 -0
  113. package/src/grafana/dashboard-model.ts +111 -0
  114. package/src/grafana/dashboard.test.ts +232 -0
  115. package/src/grafana/dashboard.ts +161 -0
  116. package/src/grafana/folder.test.ts +212 -0
  117. package/src/grafana/folder.ts +161 -0
  118. package/src/grafana/index.ts +21 -8
  119. package/src/grafana/providers.ts +7 -1
  120. package/src/grafana/provisioned.ts +49 -0
  121. package/src/linux/container-fixture.test.ts +92 -0
  122. package/src/linux/container-form.ts +243 -0
  123. package/src/linux/container-generator.test.ts +195 -0
  124. package/src/linux/container-generator.ts +113 -0
  125. package/src/linux/container-handlers.ts +73 -0
  126. package/src/linux/container-lifecycle.test.ts +209 -0
  127. package/src/linux/container-lifecycle.ts +217 -0
  128. package/src/linux/container-preflight.ts +97 -0
  129. package/src/linux/container-secrets.test.ts +171 -0
  130. package/src/linux/container-secrets.ts +112 -0
  131. package/src/linux/container-settle.ts +97 -0
  132. package/src/linux/container.ts +60 -0
  133. package/src/linux/fake-quadlet.ts +127 -0
  134. package/src/linux/index.ts +11 -3
  135. package/src/linux/providers.ts +2 -0
  136. package/src/linux/systemctl.ts +9 -0
  137. package/src/opnsense/alias-form.test.ts +89 -0
  138. package/src/opnsense/alias-form.ts +53 -0
  139. package/src/opnsense/alias.test.ts +203 -0
  140. package/src/opnsense/alias.ts +117 -0
  141. package/src/opnsense/category-form.test.ts +54 -0
  142. package/src/opnsense/category-form.ts +20 -0
  143. package/src/opnsense/category.test.ts +138 -0
  144. package/src/opnsense/category.ts +82 -0
  145. package/src/opnsense/credentials.ts +41 -0
  146. package/src/opnsense/fake-opnsense.ts +79 -0
  147. package/src/opnsense/group-form.test.ts +75 -0
  148. package/src/opnsense/group-form.ts +25 -0
  149. package/src/opnsense/group.test.ts +141 -0
  150. package/src/opnsense/group.ts +78 -0
  151. package/src/opnsense/index.ts +45 -0
  152. package/src/opnsense/policy.ts +49 -0
  153. package/src/opnsense/providers.ts +21 -0
  154. package/src/opnsense/resource.ts +146 -0
  155. package/src/opnsense/wire.ts +56 -0
  156. package/src/opnsense/write-refusal.test.ts +148 -0
  157. package/src/proxmox/constraints-host-forms.test.ts +1 -1
  158. package/src/proxmox/storage-form.ts +144 -0
  159. package/src/proxmox/storage-read-failure.test.ts +214 -0
  160. package/src/proxmox/storage-wire.ts +124 -0
  161. package/src/proxmox/storage.ts +165 -175
  162. package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
  163. package/src/proxmox/zfs-pool-form.ts +236 -0
  164. package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
  165. package/src/proxmox/zfs-pool-wire.ts +132 -0
  166. package/src/proxmox/zfs-pool.ts +106 -76
  167. package/src/unifi/fake-unifi.ts +60 -0
  168. package/src/unifi/firewall-zone-form.ts +65 -0
  169. package/src/unifi/firewall-zone.test.ts +88 -0
  170. package/src/unifi/firewall-zone.ts +66 -0
  171. package/src/unifi/index.ts +26 -0
  172. package/src/unifi/network-form.ts +207 -0
  173. package/src/unifi/network.test.ts +209 -0
  174. package/src/unifi/network.ts +83 -0
  175. package/src/unifi/policy.ts +37 -0
  176. package/src/unifi/providers.ts +15 -0
  177. package/src/unifi/resource.test.ts +133 -0
  178. package/src/unifi/resource.ts +131 -0
  179. package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
  180. package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
  181. package/dist/proxmox/zfs-pool-write.d.ts +0 -128
  182. package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
  183. package/src/proxmox/zfs-pool-spec.ts +0 -76
  184. package/src/proxmox/zfs-pool-write.ts +0 -196
@@ -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)));
@@ -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)));
@@ -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, fake-grafana.ts)
6
- * are internals a provider needs but a consumer should not depend on.
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
- * ⛔ ONLY `Grafana.Datasource` SHIPS HERE. `@distilled.cloud/grafana` (aliased onto
9
- * `@homeflare/distilled-grafana` — see docs/distilled-interim.md) now HAS folder, dashboard and
10
- * alerting-provisioning CRUD operations — the SDK-level gap docs/grafana.md and
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';
@@ -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
- GrafanaDatasourceProvider().pipe(Layer.provide(grafanaCredentials(target)));
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
+ };