@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
package/README.md CHANGED
@@ -148,14 +148,16 @@ one is logged (argv only, never content) before it runs. A plan never calls sudo
148
148
 
149
149
  ## Linux hosts — `@homeflare/alchemy/linux`
150
150
 
151
- The same `HostRunner` seam, over ssh, plus the three families the launchd subpath had no Linux twin
151
+ The same `HostRunner` seam, over ssh, plus the four families the launchd subpath had no Linux twin
152
152
  for: `HostDirectory` (because no file resource creates a parent), `RemoteFile` (a whole file, or one
153
- **managed block** inside a file somebody else owns) and `SystemdUnit` / `SystemdTimer`.
154
- `linuxProviders(await sshRunner({ host }))` provides all four.
153
+ **managed block** inside a file somebody else owns), `SystemdUnit` / `SystemdTimer`, and
154
+ `PodmanContainer` (one Quadlet `.container` file and the systemd unit its generator produces).
155
+ `linuxProviders(await sshRunner({ host }))` provides all five.
155
156
 
156
157
  ```ts
157
158
  RemoteFile('block', { path: '/etc/example.conf', region: { name: 'homeflare' }, content: 'a line\n' });
158
159
  SystemdUnit('thing', { name: 'thing.service', sections: [...], restartOn: [config.sha256] });
160
+ PodmanContainer('thing', { name: 'thing', container: { image: 'example/thing:1', network: 'host' } });
159
161
  ```
160
162
 
161
163
  - ⛔ **A deploy never mass-restarts.** A unit restarts only when its own file changed, when state or
@@ -166,7 +168,12 @@ SystemdUnit('thing', { name: 'thing.service', sections: [...], restartOn: [confi
166
168
  without its framing marker is an Error — never "nothing is there".
167
169
  - ⛔ **No silent sudo:** a root-owned path needs a root ssh destination or your own privileged runner.
168
170
  - ⛔ **The directive set is systemd's:** unit files render verbatim; nothing here invents a schema.
171
+ - ⛔ **`PodmanContainer`'s `[Container]` section IS typed and doc-checked** (Podman 5.4,
172
+ `podman-systemd.unit(5)`) — Quadlet's own schema, unlike bare `systemd.unit(5)`. It has no
173
+ `enabled` prop and never calls `systemctl enable`: Quadlet applies `[Install]` itself at every
174
+ `daemon-reload`, and a generator failure is a typed `QuadletGeneratorError`, never "absent".
169
175
  - Guide, the measured `systemctl` shapes and the limits: [docs/linux-host.md](./docs/linux-host.md).
176
+ `PodmanContainer`'s own guide: [docs/quadlet-container.md](./docs/quadlet-container.md).
170
177
 
171
178
  ## Release binaries — `@homeflare/alchemy/release`
172
179
 
@@ -248,6 +255,17 @@ endpoint lives in ONE `general_settings` field (a whole-list read-modify-write),
248
255
  declared in `config.yaml` is refused rather than silently overridden, and a literal secret in a
249
256
  forwarded header is refused at plan: [docs/litellm.md](./docs/litellm.md).
250
257
 
258
+ ## OPNsense — `@homeflare/alchemy/opnsense`
259
+
260
+ `Opnsense.Firewall.Alias`, `.Category` and `.Group` — READ-ONLY BY DESIGN, generated
261
+ from `@distilled.cloud/opnsense` (an interim alias, like NetBox/Proxmox above — not a
262
+ peer, `bun add` resolves it from the tarball). ⛔ `reconcile` and `delete` always
263
+ refuse with a typed error; there is no flag that lifts it, and no write operation is
264
+ reachable from any handler (`write-refusal.test.ts` proves it against a fake that
265
+ fails on any non-GET). `read` answers `Unowned` on every match, `adopt(true)` is on
266
+ by default, and a pure declaration renderer turns one live read into the props a
267
+ noop declaration needs: [docs/opnsense.md](./docs/opnsense.md).
268
+
251
269
  ## Discord — `@homeflare/alchemy/discord`
252
270
 
253
271
  `Discord.ApplicationCommand` (global) and `Discord.GuildApplicationCommand` (guild-scoped)
@@ -270,6 +288,24 @@ export const help =
270
288
  });
271
289
  ```
272
290
 
291
+ ## UniFi Network — `@homeflare/alchemy/unifi`
292
+
293
+ `Unifi.Network` and `Unifi.FirewallZone`, generated from Ubiquiti's own UniFi Network
294
+ Integration API 10.4.57 via `@distilled.cloud/unifi-network`. ⛔ **READ-ONLY, by Tim's rule
295
+ (2026-09-24):** `reconcile`/`delete` both fail with a typed `UnifiWriteRefused` naming the
296
+ policy — this family never calls a write operation, only `getNetworkDetails`/`getFirewallZone`
297
+ and their list counterparts. ⛔ `read` answers `Unowned` on every match (never a silent adopt —
298
+ `adopt(true)` is on by default via the `network`/`firewallZone` constructors), and a UniFi
299
+ **Console ID** (part of a cloud-connector base URL) is never logged, committed or written to
300
+ state: [docs/unifi.md](./docs/unifi.md).
301
+
302
+ ```ts
303
+ import { declareNetwork, network, providers } from '@homeflare/alchemy/unifi';
304
+
305
+ // A later import script writes rows like this one from a live read:
306
+ export const lan = network('lan', declareNetwork(liveNetworkDetails, 'site-id'));
307
+ ```
308
+
273
309
  ## Google Workspace — `@homeflare/alchemy/google-workspace`
274
310
 
275
311
  `Group`, `GroupMember`, `DomainAlias` and `OrgUnit` over the Admin SDK Directory API, generated
@@ -0,0 +1,77 @@
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
+ * ★ NORMALIZES BOTH SIDES THE SAME WAY, so a no-change plan is a true noop
9
+ * (docs/grafana-folder-dashboard.md). Strips `id`/`version`/`iteration` (Grafana-managed, never
10
+ * declared content) and forces `uid` to this resource's OWN canonical value, wherever — or
11
+ * whether — the source object carried one. That second part is "uid placement": a dashboard
12
+ * pasted from Grafana's own "Export as JSON" always embeds the OLD live uid (or none, for a
13
+ * never-saved dashboard), while the live model this resource reads back always carries the
14
+ * CURRENT one; forcing both to `props.uid` before comparing is what makes those two shapes equal
15
+ * instead of permanently disagreeing on one field neither side is meant to declare independently
16
+ * (`DashboardProps.uid` — see dashboard.ts — is the single source of truth).
17
+ *
18
+ * ⚠️ EVERY OTHER DECLARED FIELD COMPARES STRUCTURALLY, ARRAY ORDER INCLUDED — dashboard.ts's
19
+ * `matches` runs this output through THIS FILE's own `declaredContentMatches` (below), which
20
+ * never reorders an array. That is correct here: `panels` is position-significant (screen layout
21
+ * via its own `gridPos`, but the array order also drives Grafana's internal panel indexing and
22
+ * any `repeat` behavior) and each panel's `targets` is execution/legend order. Nothing in a
23
+ * dashboard model is a genuinely unordered SET the way UniFi's `trustedDhcpServerIpAddresses` is
24
+ * (unifi/network-form.ts's `sortedSet`) — this family has no field that needs one.
25
+ * ⚠️ `schemaVersion` IS DELIBERATELY **NOT** NORMALIZED — NOT MEASURED against a live instance (no
26
+ * live calls in this PR). Community reports say Grafana can rewrite an old dashboard's
27
+ * `schemaVersion` upward on load; IF that also happens on every plain save of an
28
+ * already-current-schema dashboard, every plan against it would show a false `update` forever —
29
+ * but silently normalizing it away on a guess would just as easily hide a genuine schema
30
+ * mismatch. Left comparing until measured: add it to `VOLATILE_FIELDS` with a dated, measured
31
+ * comment the day it is seen live, the same standard every other row in this file meets.
32
+ */
33
+ export declare const normalizeModel: (model: DashboardModel, uid: string) => DashboardModel;
34
+ /** The model's own declared title — reporting only. It is never compared on its own; the whole
35
+ * normalized model is, in dashboard.ts's `matches`, so a title-only mismatch still shows `update`. */
36
+ export declare const modelTitle: (model: DashboardModel) => string;
37
+ /**
38
+ * ★ `declared` NEED ONLY BE A SUBSET OF `live` — an adversarial review of this PR (2026-09-24)
39
+ * found that plain structural equality made an ordinary, never-edited dashboard show `update`
40
+ * FOREVER: Grafana's own dashboard-save schema migration rewrites any panel or target whose
41
+ * `datasource` is absent into an explicit `{type, uid}` reference to the resolved default
42
+ * datasource — documented Grafana schema-migration behavior (not re-measured against a live
43
+ * instance in this PR — no live calls). A declared panel that never sets `datasource` (the
44
+ * common case: hand-authored JSON, or anything relying on the dashboard-level default) would,
45
+ * after the FIRST create, read back with that field populated; plain `deepEqual` (this file used
46
+ * `alchemy/Diff`'s before this fix) then disagreed with the declaration on every subsequent plan,
47
+ * `update` sent the undecorated model back, and Grafana redecorated it again on the next read —
48
+ * non-convergent.
49
+ *
50
+ * This function instead asks only "does every field the DECLARATION mentions, at any depth, still
51
+ * match what is live" — `live` may carry additional fields, at any depth, that `declared` never
52
+ * mentioned (the datasource auto-decoration above, or any future Grafana-injected default,
53
+ * `fieldConfig` defaults and `pluginVersion` included) without being compared at all. A field
54
+ * `declared` DOES mention that is missing, or different, in `live` still fails the match — real
55
+ * content drift is still caught exactly as before; only fields the declaration is silent about are
56
+ * now tolerated rather than compared.
57
+ *
58
+ * ⚠️ THE TRADE-OFF THIS ACCEPTS, DELIBERATELY: removing a field from the declaration (rather than
59
+ * changing its value) can no longer force Grafana to drop it. If a panel once declared an
60
+ * explicit `datasource` and a later declaration deletes that key entirely (rather than setting a
61
+ * different one), `live` keeps whatever the last write set — the declaration is silent about that
62
+ * key now, so nothing compares it, so no `update` ever fires to change it. This is the same
63
+ * "undeclared means don't care, never means force-to-default" convention already used everywhere
64
+ * else in this kit for a plain optional prop (e.g. `datasource.ts`'s `matches` never treats an
65
+ * undeclared `access` as "must be unset"); it now also applies inside the JSON model, not just at
66
+ * the props level. Declare an explicit replacement value to change a field; omission never clears
67
+ * one that was set by a previous declaration or by Grafana itself.
68
+ *
69
+ * ⚠️ ARRAYS COMPARE BY LENGTH, THEN ELEMENT-WISE SUBSET — never by a looser "declared has fewer
70
+ * items" rule. `panels`/`targets` are position-significant (see `normalizeModel`'s header); an
71
+ * array Grafana pads or reorders on its own would still need normalizing here explicitly, the
72
+ * same as any other newly-discovered auto-decoration — this function does not paper over that.
73
+ * ⚠️ `null` ON EITHER SIDE OF A LEAF NEVER MISMATCHES (mirrors `alchemy/Diff`'s `deepEqual` with
74
+ * `stripNullish: true`, which this function replaces for the dashboard model specifically).
75
+ */
76
+ export declare const declaredContentMatches: (declared: unknown, live: unknown) => boolean;
77
+ //# sourceMappingURL=dashboard-model.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboard-model.d.ts","sourceRoot":"","sources":["../../src/grafana/dashboard-model.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAKrD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,cAAc,UAAW,cAAc,OAAO,MAAM,KAAG,cASnE,CAAC;AAEF;uGACuG;AACvG,eAAO,MAAM,UAAU,UAAW,cAAc,KAAG,MACC,CAAC;AAKrD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,eAAO,MAAM,sBAAsB,aAAc,OAAO,QAAQ,OAAO,KAAG,OAgBzE,CAAC"}
@@ -0,0 +1,98 @@
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 { type DashboardModel } from './dashboard-model.ts';
48
+ import { type GrafanaProvisionedObjectError } from './provisioned.ts';
49
+ import { type GrafanaSpec } from './resource.ts';
50
+ export type { DashboardModel };
51
+ export interface DashboardProps {
52
+ uid: string;
53
+ /** Omit for the General/root folder. */
54
+ folderUid?: string;
55
+ /** The dashboard JSON model — see the file header. `id`/`version`/`iteration`/`uid` inside it
56
+ * are ignored; `uid` above is the single source of truth. */
57
+ dashboard: DashboardModel;
58
+ }
59
+ export interface DashboardAttributes {
60
+ uid: string;
61
+ title: string;
62
+ version: number;
63
+ folderUid: string;
64
+ url: string;
65
+ /** True when Grafana reports this dashboard as file-provisioned (read-only — see provisioned.ts). */
66
+ provisioned: boolean;
67
+ provisionedExternalId: string;
68
+ /** The live model, normalized the same way `matches` compares it. */
69
+ dashboard: DashboardModel;
70
+ }
71
+ export interface GrafanaDashboard extends Resource<'Grafana.Dashboard', DashboardProps, DashboardAttributes, never> {
72
+ }
73
+ export declare const GrafanaDashboard: import("alchemy").ResourceClass<GrafanaDashboard>;
74
+ export declare const spec: GrafanaSpec<DashboardProps, grafana.DashboardFullWithMeta, DashboardAttributes, grafana.GetDashboardError | grafana.PostDashboardError | grafana.DeleteDashboardError | GrafanaProvisionedObjectError>;
75
+ export declare const handlers: {
76
+ list: () => Effect.Effect<never[], never, never>;
77
+ read: ({ olds }: {
78
+ olds: DashboardProps;
79
+ }) => Effect.Effect<DashboardAttributes | undefined, grafana.BadGateway | import("@distilled.cloud/caddy").BadRequest | grafana.BadRequest | grafana.ConfigError | import("@distilled.cloud/caddy").Conflict | import("@distilled.cloud/caddy").Forbidden | grafana.Forbidden | grafana.GatewayTimeout | GrafanaProvisionedObjectError | import("effect/unstable/http/HttpClientError").HttpClientError | grafana.InternalServerError | grafana.Locked | import("@distilled.cloud/caddy").NotFound | grafana.NotFound | grafana.PreconditionFailed | grafana.ServiceUnavailable | grafana.TooManyRequests | grafana.Unauthorized | grafana.UnknownGrafanaError | import("@distilled.cloud/caddy").UnprocessableEntity | grafana.UnprocessableEntity, grafana.GrafanaOpContext>;
80
+ diff: ({ news, output }: {
81
+ news: import("alchemy").Input<DashboardProps>;
82
+ output: DashboardAttributes | undefined;
83
+ }) => Effect.Effect<{
84
+ readonly action: 'update';
85
+ } | {
86
+ readonly action: 'noop';
87
+ } | {
88
+ readonly action: 'replace';
89
+ } | undefined, grafana.BadGateway | import("@distilled.cloud/caddy").BadRequest | grafana.BadRequest | grafana.ConfigError | import("@distilled.cloud/caddy").Conflict | import("@distilled.cloud/caddy").Forbidden | grafana.Forbidden | grafana.GatewayTimeout | GrafanaProvisionedObjectError | import("effect/unstable/http/HttpClientError").HttpClientError | grafana.InternalServerError | grafana.Locked | import("@distilled.cloud/caddy").NotFound | grafana.NotFound | grafana.PreconditionFailed | grafana.ServiceUnavailable | grafana.TooManyRequests | grafana.Unauthorized | grafana.UnknownGrafanaError | import("@distilled.cloud/caddy").UnprocessableEntity | grafana.UnprocessableEntity, grafana.GrafanaOpContext>;
90
+ reconcile: ({ news }: {
91
+ news: DashboardProps;
92
+ }) => Effect.Effect<DashboardAttributes, grafana.BadGateway | import("@distilled.cloud/caddy").BadRequest | grafana.BadRequest | grafana.ConfigError | import("@distilled.cloud/caddy").Conflict | import("@distilled.cloud/caddy").Forbidden | grafana.Forbidden | grafana.GatewayTimeout | GrafanaProvisionedObjectError | import("effect/unstable/http/HttpClientError").HttpClientError | grafana.InternalServerError | grafana.Locked | import("@distilled.cloud/caddy").NotFound | grafana.NotFound | grafana.PreconditionFailed | grafana.ServiceUnavailable | grafana.TooManyRequests | grafana.Unauthorized | grafana.UnknownGrafanaError | import("@distilled.cloud/caddy").UnprocessableEntity | grafana.UnprocessableEntity, grafana.GrafanaOpContext>;
93
+ delete: ({ olds }: {
94
+ olds: DashboardProps;
95
+ }) => Effect.Effect<void, grafana.BadGateway | import("@distilled.cloud/caddy").BadRequest | grafana.BadRequest | grafana.ConfigError | import("@distilled.cloud/caddy").Conflict | import("@distilled.cloud/caddy").Forbidden | grafana.Forbidden | grafana.GatewayTimeout | GrafanaProvisionedObjectError | import("effect/unstable/http/HttpClientError").HttpClientError | grafana.InternalServerError | grafana.Locked | import("@distilled.cloud/caddy").NotFound | grafana.NotFound | grafana.PreconditionFailed | grafana.ServiceUnavailable | grafana.TooManyRequests | grafana.Unauthorized | grafana.UnknownGrafanaError | import("@distilled.cloud/caddy").UnprocessableEntity | grafana.UnprocessableEntity, grafana.GrafanaOpContext>;
96
+ };
97
+ export declare const GrafanaDashboardProvider: () => import("effect/Layer").Layer<Provider.Provider<GrafanaDashboard>, never, grafana.GrafanaOpContext>;
98
+ //# sourceMappingURL=dashboard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboard.d.ts","sourceRoot":"","sources":["../../src/grafana/dashboard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,OAAO,MAAM,0BAA0B,CAAC;AACpD,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EACL,KAAK,cAAc,EAIpB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,KAAK,6BAA6B,EAAuB,MAAM,kBAAkB,CAAC;AAC3F,OAAO,EAAE,KAAK,WAAW,EAAmB,MAAM,eAAe,CAAC;AAElE,YAAY,EAAE,cAAc,EAAE,CAAC;AAE/B,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;kEAC8D;IAC9D,SAAS,EAAE,cAAc,CAAC;CAC3B;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,qGAAqG;IACrG,WAAW,EAAE,OAAO,CAAC;IACrB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,qEAAqE;IACrE,SAAS,EAAE,cAAc,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAiB,SAAQ,QAAQ,CAChD,mBAAmB,EACnB,cAAc,EACd,mBAAmB,EACnB,KAAK,CACN;CAAG;AAEJ,eAAO,MAAM,gBAAgB,mDAAkD,CAAC;AAOhF,eAAO,MAAM,IAAI,EAAE,WAAW,CAC5B,cAAc,EACd,OAAO,CAAC,qBAAqB,EAC7B,mBAAmB,EACjB,OAAO,CAAC,iBAAiB,GACzB,OAAO,CAAC,kBAAkB,GAC1B,OAAO,CAAC,oBAAoB,GAC5B,6BAA6B,CAuDhC,CAAC;AAEF,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;CAAwB,CAAC;AAE9C,eAAO,MAAM,wBAAwB,0GACsD,CAAC"}
@@ -0,0 +1,100 @@
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 Effect from 'effect/Effect';
44
+ import { type GrafanaProvisionedObjectError } from './provisioned.ts';
45
+ import { type GrafanaSpec } from './resource.ts';
46
+ export interface FolderProps {
47
+ uid: string;
48
+ title: string;
49
+ /** Write-only — see the file header. Sent on every create/update that declares it; never diffed. */
50
+ description?: string;
51
+ /** Parent folder UID, for nested folders. Create-only — see the file header. */
52
+ parentUid?: string;
53
+ }
54
+ export interface FolderAttributes {
55
+ uid: string;
56
+ id: number;
57
+ title: string;
58
+ parentUid: string;
59
+ /** Non-empty when Grafana reports this folder as externally managed (e.g. provisioning). */
60
+ managedBy: string;
61
+ url: string;
62
+ version: number;
63
+ }
64
+ export interface GrafanaFolder extends Resource<'Grafana.Folder', FolderProps, FolderAttributes, never> {
65
+ }
66
+ export declare const GrafanaFolder: import("alchemy").ResourceClass<GrafanaFolder>;
67
+ declare const GrafanaFolderReparentError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
68
+ readonly _tag: "GrafanaFolderReparentError";
69
+ } & Readonly<A>;
70
+ /** A declared `parentUid` disagrees with the live folder's — `updateFolder` cannot reparent. */
71
+ export declare class GrafanaFolderReparentError extends GrafanaFolderReparentError_base<{
72
+ readonly message: string;
73
+ }> {
74
+ }
75
+ export declare const spec: GrafanaSpec<FolderProps, grafana.Folder, FolderAttributes, grafana.CreateFolderError | grafana.GetFolderByUIDError | grafana.UpdateFolderError | grafana.DeleteFolderError | GrafanaProvisionedObjectError | GrafanaFolderReparentError>;
76
+ export declare const handlers: {
77
+ list: () => Effect.Effect<never[], never, never>;
78
+ read: ({ olds }: {
79
+ olds: FolderProps;
80
+ }) => Effect.Effect<FolderAttributes | undefined, grafana.BadGateway | import("@distilled.cloud/caddy").BadRequest | grafana.BadRequest | grafana.ConfigError | import("@distilled.cloud/caddy").Conflict | grafana.Conflict | import("@distilled.cloud/caddy").Forbidden | grafana.Forbidden | grafana.GatewayTimeout | GrafanaFolderReparentError | GrafanaProvisionedObjectError | import("effect/unstable/http/HttpClientError").HttpClientError | grafana.InternalServerError | grafana.Locked | import("@distilled.cloud/caddy").NotFound | grafana.NotFound | grafana.ServiceUnavailable | grafana.TooManyRequests | grafana.Unauthorized | grafana.UnknownGrafanaError | import("@distilled.cloud/caddy").UnprocessableEntity, grafana.GrafanaOpContext>;
81
+ diff: ({ news, output }: {
82
+ news: import("alchemy").Input<FolderProps>;
83
+ output: FolderAttributes | undefined;
84
+ }) => Effect.Effect<{
85
+ readonly action: 'update';
86
+ } | {
87
+ readonly action: 'noop';
88
+ } | {
89
+ readonly action: 'replace';
90
+ } | undefined, grafana.BadGateway | import("@distilled.cloud/caddy").BadRequest | grafana.BadRequest | grafana.ConfigError | import("@distilled.cloud/caddy").Conflict | grafana.Conflict | import("@distilled.cloud/caddy").Forbidden | grafana.Forbidden | grafana.GatewayTimeout | GrafanaFolderReparentError | GrafanaProvisionedObjectError | import("effect/unstable/http/HttpClientError").HttpClientError | grafana.InternalServerError | grafana.Locked | import("@distilled.cloud/caddy").NotFound | grafana.NotFound | grafana.ServiceUnavailable | grafana.TooManyRequests | grafana.Unauthorized | grafana.UnknownGrafanaError | import("@distilled.cloud/caddy").UnprocessableEntity, grafana.GrafanaOpContext>;
91
+ reconcile: ({ news }: {
92
+ news: FolderProps;
93
+ }) => Effect.Effect<FolderAttributes, grafana.BadGateway | import("@distilled.cloud/caddy").BadRequest | grafana.BadRequest | grafana.ConfigError | import("@distilled.cloud/caddy").Conflict | grafana.Conflict | import("@distilled.cloud/caddy").Forbidden | grafana.Forbidden | grafana.GatewayTimeout | GrafanaFolderReparentError | GrafanaProvisionedObjectError | import("effect/unstable/http/HttpClientError").HttpClientError | grafana.InternalServerError | grafana.Locked | import("@distilled.cloud/caddy").NotFound | grafana.NotFound | grafana.ServiceUnavailable | grafana.TooManyRequests | grafana.Unauthorized | grafana.UnknownGrafanaError | import("@distilled.cloud/caddy").UnprocessableEntity, grafana.GrafanaOpContext>;
94
+ delete: ({ olds }: {
95
+ olds: FolderProps;
96
+ }) => Effect.Effect<void, grafana.BadGateway | import("@distilled.cloud/caddy").BadRequest | grafana.BadRequest | grafana.ConfigError | import("@distilled.cloud/caddy").Conflict | grafana.Conflict | import("@distilled.cloud/caddy").Forbidden | grafana.Forbidden | grafana.GatewayTimeout | GrafanaFolderReparentError | GrafanaProvisionedObjectError | import("effect/unstable/http/HttpClientError").HttpClientError | grafana.InternalServerError | grafana.Locked | import("@distilled.cloud/caddy").NotFound | grafana.NotFound | grafana.ServiceUnavailable | grafana.TooManyRequests | grafana.Unauthorized | grafana.UnknownGrafanaError | import("@distilled.cloud/caddy").UnprocessableEntity, grafana.GrafanaOpContext>;
97
+ };
98
+ export declare const GrafanaFolderProvider: () => import("effect/Layer").Layer<Provider.Provider<GrafanaFolder>, never, grafana.GrafanaOpContext>;
99
+ export {};
100
+ //# sourceMappingURL=folder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"folder.d.ts","sourceRoot":"","sources":["../../src/grafana/folder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,OAAO,MAAM,0BAA0B,CAAC;AAEpD,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EAAE,KAAK,6BAA6B,EAAuB,MAAM,kBAAkB,CAAC;AAC3F,OAAO,EAAE,KAAK,WAAW,EAAmB,MAAM,eAAe,CAAC;AAElE,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,oGAAoG;IACpG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gFAAgF;IAChF,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,4FAA4F;IAC5F,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAc,SAAQ,QAAQ,CAC7C,gBAAgB,EAChB,WAAW,EACX,gBAAgB,EAChB,KAAK,CACN;CAAG;AAEJ,eAAO,MAAM,aAAa,gDAExB,CAAC;;;;AAEH,gGAAgG;AAChG,qBAAa,0BAA2B,SAAQ,gCAA+C;IAC7F,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,CAAC;CAAG;AAEL,eAAO,MAAM,IAAI,EAAE,WAAW,CAC5B,WAAW,EACX,OAAO,CAAC,MAAM,EACd,gBAAgB,EACd,OAAO,CAAC,iBAAiB,GACzB,OAAO,CAAC,mBAAmB,GAC3B,OAAO,CAAC,iBAAiB,GACzB,OAAO,CAAC,iBAAiB,GACzB,6BAA6B,GAC7B,0BAA0B,CA+D7B,CAAC;AAEF,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;CAAwB,CAAC;AAE9C,eAAO,MAAM,qBAAqB,uGACmD,CAAC"}
@@ -2,17 +2,18 @@
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 { type DatasourceAttributes, type DatasourceProps, GrafanaDatasource, GrafanaDatasourceProvider, GrafanaSecretRefUnsetError, } from './datasource.ts';
14
+ export { type DashboardAttributes, type DashboardModel, type DashboardProps, GrafanaDashboard, GrafanaDashboardProvider, } from './dashboard.ts';
15
+ export { type FolderAttributes, type FolderProps, GrafanaFolder, GrafanaFolderProvider, GrafanaFolderReparentError, } from './folder.ts';
16
+ export { GrafanaProvisionedObjectError } from './provisioned.ts';
16
17
  export { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
17
18
  export { grafanaProviders } from './providers.ts';
18
19
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/grafana/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,eAAe,EACpB,iBAAiB,EACjB,yBAAyB,EACzB,0BAA0B,GAC3B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,KAAK,aAAa,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC1E,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/grafana/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,eAAe,EACpB,iBAAiB,EACjB,yBAAyB,EACzB,0BAA0B,GAC3B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,aAAa,EACb,qBAAqB,EACrB,0BAA0B,GAC3B,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,6BAA6B,EAAE,MAAM,kBAAkB,CAAC;AACjE,OAAO,EAAE,KAAK,aAAa,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC1E,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC"}
@@ -120,30 +120,182 @@ var spec = {
120
120
  };
121
121
  var handlers = grafanaHandlers(spec);
122
122
  var GrafanaDatasourceProvider = () => Provider.effect(GrafanaDatasource, Effect2.succeed(GrafanaDatasource.Provider.of(handlers)));
123
+ // src/grafana/dashboard.ts
124
+ import { Resource as Resource2 } from "alchemy";
125
+ import * as Provider2 from "alchemy/Provider";
126
+ import * as grafana2 from "@distilled.cloud/grafana";
127
+ import * as Effect4 from "effect/Effect";
128
+
129
+ // src/grafana/dashboard-model.ts
130
+ var VOLATILE_FIELDS = ["id", "version", "iteration"];
131
+ var normalizeModel = (model, uid) => {
132
+ const normalized = {};
133
+ for (const [key, value] of Object.entries(model)) {
134
+ if (key === "uid")
135
+ continue;
136
+ if (VOLATILE_FIELDS.includes(key))
137
+ continue;
138
+ normalized[key] = value;
139
+ }
140
+ normalized.uid = uid;
141
+ return normalized;
142
+ };
143
+ var modelTitle = (model) => typeof model.title === "string" ? model.title : "";
144
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
145
+ var declaredContentMatches = (declared, live) => {
146
+ if (declared == null)
147
+ return true;
148
+ if (Array.isArray(declared)) {
149
+ return Array.isArray(live) && declared.length === live.length && declared.every((item, index) => declaredContentMatches(item, live[index]));
150
+ }
151
+ if (isPlainObject(declared)) {
152
+ return isPlainObject(live) && Object.entries(declared).every(([key, value]) => declaredContentMatches(value, live[key]));
153
+ }
154
+ return declared === live;
155
+ };
156
+
157
+ // src/grafana/provisioned.ts
158
+ import * as Data2 from "effect/Data";
159
+ import * as Effect3 from "effect/Effect";
160
+
161
+ class GrafanaProvisionedObjectError extends Data2.TaggedError("GrafanaProvisionedObjectError") {
162
+ }
163
+ var refuseIfProvisioned = (kind, uid, owner) => {
164
+ const ownerClause = owner === undefined || owner === "" ? `(this Grafana instance did not report an owning file)` : `by ${owner}`;
165
+ return Effect3.fail(new GrafanaProvisionedObjectError({
166
+ message: `${kind} '${uid}' is file-provisioned ${ownerClause} and read-only through the classic ` + `write API this family calls. Edit the provisioning file instead — declaring this ` + `object read-only (a declaration matching what is live) is safe and will never reach ` + `this refusal.`
167
+ }));
168
+ };
169
+
170
+ // src/grafana/dashboard.ts
171
+ var GrafanaDashboard = Resource2("Grafana.Dashboard");
172
+ var modelOf = (live) => live.dashboard !== null && typeof live.dashboard === "object" ? live.dashboard : {};
173
+ var spec2 = {
174
+ attributes: (live, props) => {
175
+ const model = normalizeModel(modelOf(live), props.uid);
176
+ return {
177
+ dashboard: model,
178
+ folderUid: live.meta?.folderUid ?? "",
179
+ provisioned: live.meta?.provisioned ?? false,
180
+ provisionedExternalId: live.meta?.provisionedExternalId ?? "",
181
+ title: modelTitle(model),
182
+ uid: props.uid,
183
+ url: live.meta?.url ?? "",
184
+ version: live.meta?.version ?? 0
185
+ };
186
+ },
187
+ create: (props) => grafana2.postDashboard({
188
+ dashboard: normalizeModel(props.dashboard, props.uid),
189
+ ...props.folderUid === undefined ? {} : { folderUid: props.folderUid }
190
+ }),
191
+ destroy: (props, live) => Effect4.gen(function* () {
192
+ if (live.meta?.provisioned) {
193
+ return yield* refuseIfProvisioned("Grafana.Dashboard", props.uid, live.meta.provisionedExternalId);
194
+ }
195
+ yield* grafana2.deleteDashboard({ uid: props.uid });
196
+ }),
197
+ fetchLive: (props) => grafana2.getDashboard({ uid: props.uid }).pipe(Effect4.catchTag("NotFound", () => Effect4.succeed(undefined))),
198
+ matches: (attributes, props) => (props.folderUid === undefined || attributes.folderUid === props.folderUid) && declaredContentMatches(normalizeModel(props.dashboard, props.uid), attributes.dashboard),
199
+ update: (props, live) => Effect4.gen(function* () {
200
+ if (live.meta?.provisioned) {
201
+ return yield* refuseIfProvisioned("Grafana.Dashboard", props.uid, live.meta.provisionedExternalId);
202
+ }
203
+ return yield* grafana2.postDashboard({
204
+ dashboard: {
205
+ ...normalizeModel(props.dashboard, props.uid),
206
+ ...live.meta?.version === undefined ? {} : { version: live.meta.version }
207
+ },
208
+ ...props.folderUid === undefined ? {} : { folderUid: props.folderUid }
209
+ });
210
+ })
211
+ };
212
+ var handlers2 = grafanaHandlers(spec2);
213
+ var GrafanaDashboardProvider = () => Provider2.effect(GrafanaDashboard, Effect4.succeed(GrafanaDashboard.Provider.of(handlers2)));
214
+ // src/grafana/folder.ts
215
+ import { Resource as Resource3 } from "alchemy";
216
+ import * as Provider3 from "alchemy/Provider";
217
+ import * as grafana3 from "@distilled.cloud/grafana";
218
+ import * as Data3 from "effect/Data";
219
+ import * as Effect5 from "effect/Effect";
220
+ var GrafanaFolder = Resource3("Grafana.Folder", {
221
+ defaultRemovalPolicy: "retain"
222
+ });
223
+
224
+ class GrafanaFolderReparentError extends Data3.TaggedError("GrafanaFolderReparentError") {
225
+ }
226
+ var spec3 = {
227
+ attributes: (live) => ({
228
+ id: live.id ?? 0,
229
+ managedBy: live.managedBy ?? "",
230
+ parentUid: live.parentUid ?? "",
231
+ title: live.title ?? "",
232
+ uid: live.uid ?? "",
233
+ url: live.url ?? "",
234
+ version: live.version ?? 0
235
+ }),
236
+ create: (props) => grafana3.createFolder({
237
+ uid: props.uid,
238
+ title: props.title,
239
+ ...props.description === undefined ? {} : { description: props.description },
240
+ ...props.parentUid === undefined ? {} : { parentUid: props.parentUid }
241
+ }),
242
+ destroy: (props, live) => Effect5.gen(function* () {
243
+ if (live.managedBy) {
244
+ return yield* refuseIfProvisioned("Grafana.Folder", props.uid, live.managedBy);
245
+ }
246
+ yield* grafana3.deleteFolder({ folder_uid: props.uid });
247
+ }),
248
+ fetchLive: (props) => grafana3.getFolderByUID({ folder_uid: props.uid }).pipe(Effect5.catchTag("NotFound", () => Effect5.succeed(undefined))),
249
+ matches: (attributes, props) => attributes.title === props.title && (props.parentUid === undefined || attributes.parentUid === props.parentUid),
250
+ update: (props, live) => Effect5.gen(function* () {
251
+ if (live.managedBy) {
252
+ return yield* refuseIfProvisioned("Grafana.Folder", props.uid, live.managedBy);
253
+ }
254
+ if (props.parentUid !== undefined && props.parentUid !== (live.parentUid ?? undefined)) {
255
+ return yield* Effect5.fail(new GrafanaFolderReparentError({
256
+ message: `Grafana.Folder '${props.uid}' declares parentUid '${props.parentUid}' but is ` + `live under '${live.parentUid ?? "(root)"}'. PUT /folders/{uid} cannot reparent a ` + `folder — move it by hand in Grafana, then update the declaration to match.`
257
+ }));
258
+ }
259
+ return yield* grafana3.updateFolder({
260
+ folder_uid: props.uid,
261
+ title: props.title,
262
+ ...props.description === undefined ? {} : { description: props.description },
263
+ ...live.version === undefined ? {} : { version: live.version }
264
+ });
265
+ })
266
+ };
267
+ var handlers3 = grafanaHandlers(spec3);
268
+ var GrafanaFolderProvider = () => Provider3.effect(GrafanaFolder, Effect5.succeed(GrafanaFolder.Provider.of(handlers3)));
123
269
  // src/grafana/credentials.ts
124
270
  import { Credentials } from "@distilled.cloud/grafana/Credentials";
125
271
  import { ConfigError } from "@distilled.cloud/grafana/Errors";
126
- import * as Effect3 from "effect/Effect";
272
+ import * as Effect6 from "effect/Effect";
127
273
  import * as Layer from "effect/Layer";
128
274
  import * as Redacted from "effect/Redacted";
129
- var resolve = (target) => Effect3.sync(() => process.env[target.tokenEnv]).pipe(Effect3.flatMap((raw) => raw === undefined || raw.trim() === "" ? Effect3.fail(new ConfigError({
275
+ var resolve = (target) => Effect6.sync(() => process.env[target.tokenEnv]).pipe(Effect6.flatMap((raw) => raw === undefined || raw.trim() === "" ? Effect6.fail(new ConfigError({
130
276
  message: `${target.tokenEnv} is not set. Export the Grafana service-account token there ` + `— never as an Alchemy prop — before running a stack that declares a resource ` + `against ${target.baseUrl}.`
131
- })) : Effect3.succeed({
277
+ })) : Effect6.succeed({
132
278
  apiBaseUrl: target.baseUrl.replace(/\/+$/, ""),
133
279
  token: Redacted.make(raw.trim()),
134
280
  ...target.orgId === undefined ? {} : { orgId: target.orgId }
135
- })), Effect3.orDie);
281
+ })), Effect6.orDie);
136
282
  var grafanaCredentials = (target) => Layer.succeed(Credentials, resolve(target));
137
283
  // src/grafana/providers.ts
138
284
  import * as Layer2 from "effect/Layer";
139
- var grafanaProviders = (target) => GrafanaDatasourceProvider().pipe(Layer2.provide(grafanaCredentials(target)));
285
+ var grafanaProviders = (target) => Layer2.mergeAll(GrafanaDatasourceProvider(), GrafanaFolderProvider(), GrafanaDashboardProvider()).pipe(Layer2.provide(grafanaCredentials(target)));
140
286
  export {
287
+ GrafanaDashboard,
288
+ GrafanaDashboardProvider,
141
289
  GrafanaDatasource,
142
290
  GrafanaDatasourceProvider,
291
+ GrafanaFolder,
292
+ GrafanaFolderProvider,
293
+ GrafanaFolderReparentError,
294
+ GrafanaProvisionedObjectError,
143
295
  GrafanaSecretRefUnsetError,
144
296
  grafanaCredentials,
145
297
  grafanaProviders
146
298
  };
147
299
 
148
- //# debugId=DA921F3915B68A6164756E2164756E21
300
+ //# debugId=D031E5F2BA23E8BC64756E2164756E21
149
301
  //# sourceMappingURL=index.js.map