@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/docs/unifi.md ADDED
@@ -0,0 +1,93 @@
1
+ # UniFi Network — `@homeflare/alchemy/unifi`
2
+
3
+ ⛔ **READ-ONLY, BY TIM'S RULE (2026-09-24).** `Unifi.Network` and `Unifi.FirewallZone` read and
4
+ adopt; neither has a create, an update body or a working delete. `reconcile`/`delete` both fail
5
+ with a typed `UnifiWriteRefused` naming the policy rather than calling any SDK write operation.
6
+ Lifting the rule is a kit change — a PR that adds a write path, reviewed as one — not a flag a
7
+ stack can pass. See `packages/alchemy/src/unifi/policy.ts`.
8
+
9
+ ## What's declared, and why these two first
10
+
11
+ `Unifi.Network` (`getNetworkDetails`/`getNetworksOverviewPage`) and `Unifi.FirewallZone`
12
+ (`getFirewallZone`/`getFirewallZones`) — both list+get pairs over genuine, stable configuration,
13
+ not runtime state (a connected client, a device statistic, a hotspot voucher, a switch stack).
14
+ `Site` was the SDK README's other first suggestion; it has no `getSite`, only the list operation,
15
+ so it fails this house's own "both a list and a get" bar for a first import. `FirewallPolicy` is
16
+ also a list+get config object, but its `source`/`destination`/`ipProtocolScope` fields carry
17
+ several of the vendor's converter-flattened discriminator variants each, and its ordering
18
+ endpoint replaces the whole rule list — modelling that correctly is a bigger, separate PR.
19
+
20
+ ## Credentials
21
+
22
+ Two environment variables, read **at call time**, never props — the SDK's own
23
+ `CredentialsFromEnv` (`@distilled.cloud/unifi-network/Credentials`):
24
+
25
+ | variable | holds |
26
+ | ---------------------------- | --------------------------------------------------- |
27
+ | `UNIFI_NETWORK_API_KEY` | an Integrations → API Key from the console's own UI |
28
+ | `UNIFI_NETWORK_API_BASE_URL` | the console's integration API root — see below |
29
+
30
+ ⛔ **Neither has a default**, and neither is ever logged, committed or persisted in state.
31
+
32
+ ⛔ **No console identifier, endpoint host or other live value ever appears in code, tests,
33
+ fixtures, commits or a PR.** The base URL shape depends on how the console is reached: a cloud
34
+ connector's is `https://api.ui.com/v1/connector/consoles/<consoleId>/proxy/network/integration`
35
+ (`<consoleId>` is an account identifier); a local console is reached directly,
36
+ `<endpoint>/proxy/network/integration`, where `<endpoint>` is a hostname behind a TLS certificate
37
+ the caller trusts. Treat any UniFi base URL as sensitive; this package's tests use only the RFC
38
+ 2606 placeholder `https://unifi.example.com`.
39
+
40
+ The estate's own credential lives in OpenBao at `kv/infra/unifi/api` — fields `api_key`,
41
+ `connector_id`, `console`, `endpoint`, `site`, `transport` (`transport=local`: this console is
42
+ reached directly, not through the cloud connector). A separate `kv/infra/unifi/site-manager`
43
+ token is the _cloud_ Site Manager API credential, a different door (below) this family never
44
+ reads.
45
+
46
+ ✅ **Auth was measured live, read-only, on 2026-09-24.** The OpenAPI document declares no
47
+ `securitySchemes` at all — `X-API-KEY` comes from Ubiquiti's own Integration API guide, not the
48
+ machine-readable spec (`@distilled.cloud/unifi-network/src/credentials.ts`'s own header) — but a
49
+ hand-run, read-only probe confirmed it works against the estate's local console: `X-API-KEY`
50
+ returns HTTP 200 on both `GET /v1/info` (key: `applicationVersion`) and `GET /v1/sites` (keys:
51
+ `count`, `data`, `limit`, `offset`, `totalCount`). The _same_ key against the `api.ui.com` cloud
52
+ connector returns 401 — the wrong door for a key minted on a local console, not evidence the
53
+ cloud shape rejects `X-API-KEY` outright; `kv/infra/unifi/site-manager` is the separate cloud
54
+ credential, untouched by this family. Only reads were probed, by hand, outside this package's own
55
+ test suite — nothing in this PR's code exercises the vendor API live, and a write path would still
56
+ need its own verification.
57
+
58
+ ## Adopt is the default posture, and it never writes
59
+
60
+ `adopt(true)` is piped onto every resource by its convenience constructor (`network(...)`,
61
+ `firewallZone(...)`) — H5. A cold read of a live match answers `Unowned(attrs)` (H1, the
62
+ `Cloudflare/Snippets/Snippet.ts` reference every marker-less family here follows: UniFi Network
63
+ objects carry no tag or metadata field this stack could stamp), and `adopt(true)` turns that into
64
+ a silent bind instead of an `OwnedBySomeoneElse` refusal — the deploy never needs `--adopt`.
65
+
66
+ ⚠️ **A forced post-adoption reconcile still runs once (H6), and it still makes no write.**
67
+ Beta.79 forces one `reconcile` call after every cold adoption whether or not `diff` said noop.
68
+ `resource.ts`'s `reconcile` handles this the same way `discord/resource.ts` does: it reads the
69
+ live object again and only refuses when it is missing (would need a create) or drifted (would
70
+ need a write); an exact match returns the live attributes and calls nothing.
71
+
72
+ ## `list` answers `[]`
73
+
74
+ `GET /v1/sites/{siteId}/networks` answers every network on the site. Adoption stays explicit —
75
+ the same reasoning `Proxmox.User`'s and NetBox's own `list` give.
76
+
77
+ ## The declaration renderer
78
+
79
+ `declareNetwork(live, siteId)` and `declareFirewallZone(live, siteId)` are pure functions: given
80
+ one `getNetworkDetails`/`getFirewallZone` response, they return the `Props` a declaration needs
81
+ so its plan is a no-op. A later import script reads every object on a site and writes one
82
+ `network('<id>', declareNetwork(live, siteId))` row per result into `alchemy.run.ts` — nothing
83
+ here performs that write; these two functions are what the script would call.
84
+
85
+ ## Vendor version
86
+
87
+ UniFi Network Integration API **10.4.57** (OpenAPI 3.1.0, 44 paths), via
88
+ `@distilled.cloud/unifi-network` aliased onto `@homeflare/distilled-unifi-network@0.2.0`
89
+ (`docs/distilled-interim.md`). Traps in the underlying API that this read-only family cannot
90
+ trigger but a future write path must respect — whole-object `PUT`, ordering endpoints that
91
+ replace the whole list, `removeDevice` unadopting and factory-resetting hardware — are recorded
92
+ in the distilled package's own `README.md` and in the estate's `docs/unifi-api-notes.md`
93
+ (outside this repo).
@@ -259,10 +259,15 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
259
259
  `@homeflare/distilled-grafana` (0.1.0 pre-release, 0.2.0 once this PR's `minor` changeset
260
260
  releases it), aliased onto `@distilled.cloud/grafana` the same S22 route
261
261
  `@homeflare/distilled-netbox` established. `Grafana.Datasource`'s props/attributes/generated code
262
- are unchanged (diffed operation-by-operation against the prior commit). **Still open, and not
263
- this PR's scope:** no `Grafana.Folder`/`Dashboard`/`AlertRule`/`ContactPoint`/
264
- `NotificationPolicy`/`MuteTiming` resource calls the newly-available operations yet — follow-up
265
- PRs, no longer blocked on the SDK.
262
+ are unchanged (diffed operation-by-operation against the prior commit).
263
+
264
+ ✅ **`Grafana.Folder`/`Grafana.Dashboard` shipped** (this PR, on top of the same
265
+ `@distilled.cloud/grafana` operations S22 unblocked) — the uid-required doctrine, the
266
+ provisioned-object refusal, the parentUid-is-create-only refusal and the volatile-field
267
+ normalization that makes an unchanged dashboard a true noop are all in
268
+ [grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here. **Still open,
269
+ and not this PR's scope:** `Grafana.AlertRule`/`ContactPoint`/`NotificationPolicy`/`MuteTiming`
270
+ — follow-up PRs, no longer blocked on the SDK.
266
271
  - ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
267
272
  (H1), and credentials come from an explicit env var NAME at call time rather than an
268
273
  `alchemy/Auth` provider (S24) — here the house's own `grafanaCredentials(target)`, not the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.31.2",
3
+ "version": "0.33.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -100,6 +100,14 @@
100
100
  "types": "./dist/discord/index.d.ts",
101
101
  "default": "./dist/discord/index.js"
102
102
  },
103
+ "./unifi": {
104
+ "types": "./dist/unifi/index.d.ts",
105
+ "default": "./dist/unifi/index.js"
106
+ },
107
+ "./opnsense": {
108
+ "types": "./dist/opnsense/index.d.ts",
109
+ "default": "./dist/opnsense/index.js"
110
+ },
103
111
  "./package.json": "./package.json"
104
112
  },
105
113
  "publishConfig": {
@@ -110,9 +118,11 @@
110
118
  "@distilled.cloud/grafana": "npm:@homeflare/distilled-grafana@0.2.0",
111
119
  "@distilled.cloud/litellm": "npm:@homeflare/distilled-litellm@0.2.0",
112
120
  "@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
121
+ "@distilled.cloud/opnsense": "npm:@homeflare/distilled-opnsense@0.2.0",
113
122
  "@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
114
123
  "@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.2.0",
115
- "@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.2.0"
124
+ "@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.2.0",
125
+ "@distilled.cloud/unifi-network": "npm:@homeflare/distilled-unifi-network@0.2.0"
116
126
  },
117
127
  "peerDependencies": {
118
128
  "@distilled.cloud/cloudflare": "1.0.0-rc.12",
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `normalizeModel`/`modelTitle`/`declaredContentMatches` in isolation — no SDK, no fake, no
3
+ * Effect. Proves the volatile fields (`id`, `version`, `iteration`) are stripped and `uid` is
4
+ * forced to the canonical value regardless of where — or whether — the source model carried one,
5
+ * and that `declaredContentMatches` tolerates Grafana's own schema-migration auto-decoration
6
+ * without hiding genuine content drift. All of which is what makes a pasted "Export as JSON" and
7
+ * a freshly-read live model compare equal in dashboard.test.ts.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { declaredContentMatches, modelTitle, normalizeModel } from './dashboard-model.ts';
11
+
12
+ describe('normalizeModel', () => {
13
+ test('strips id, version and iteration', () => {
14
+ const model = { id: 42, iteration: 1700000000, title: 'Fleet', version: 7 };
15
+ expect(normalizeModel(model, 'fleet')).toEqual({ title: 'Fleet', uid: 'fleet' });
16
+ });
17
+
18
+ test('forces uid to the canonical value, overwriting a stale embedded one', () => {
19
+ const exported = { title: 'Fleet', uid: 'some-old-uid-from-an-export' };
20
+ expect(normalizeModel(exported, 'fleet')).toEqual({ title: 'Fleet', uid: 'fleet' });
21
+ });
22
+
23
+ test('injects uid when the source model has none at all — a never-saved dashboard', () => {
24
+ const neverSaved = { title: 'Fleet' };
25
+ expect(normalizeModel(neverSaved, 'fleet')).toEqual({ title: 'Fleet', uid: 'fleet' });
26
+ });
27
+
28
+ test('a declared model and a live model that only disagree on volatile fields normalize equal', () => {
29
+ const declared = { panels: [{ id: 1, title: 'CPU' }], title: 'Fleet' };
30
+ const live = {
31
+ id: 42,
32
+ iteration: 1700000000,
33
+ panels: [{ id: 1, title: 'CPU' }],
34
+ title: 'Fleet',
35
+ uid: 'fleet',
36
+ version: 9,
37
+ };
38
+ expect(normalizeModel(declared, 'fleet')).toEqual(normalizeModel(live, 'fleet'));
39
+ });
40
+
41
+ test('preserves array order — panels and targets are position-significant, not a set', () => {
42
+ const model = { panels: [{ title: 'B' }, { title: 'A' }] };
43
+ expect(normalizeModel(model, 'x').panels).toEqual([{ title: 'B' }, { title: 'A' }]);
44
+ });
45
+
46
+ test('leaves an unrelated field genuinely different, so a real content change still shows', () => {
47
+ const declared = { title: 'Fleet v2' };
48
+ const live = { title: 'Fleet', uid: 'fleet' };
49
+ expect(normalizeModel(declared, 'fleet')).not.toEqual(normalizeModel(live, 'fleet'));
50
+ });
51
+ });
52
+
53
+ describe('modelTitle', () => {
54
+ test('reads the model title', () => {
55
+ expect(modelTitle({ title: 'Fleet' })).toBe('Fleet');
56
+ });
57
+
58
+ test('answers empty for a model with no title', () => {
59
+ expect(modelTitle({})).toBe('');
60
+ });
61
+ });
62
+
63
+ describe('declaredContentMatches', () => {
64
+ // ⛔ THE REGRESSION THIS FUNCTION EXISTS TO FIX — found by an adversarial review of this PR.
65
+ // Grafana's own dashboard-save schema migration decorates a panel/target whose `datasource`
66
+ // is absent with an explicit `{type, uid}` reference. Before this function existed, a
67
+ // declared panel that never set `datasource` disagreed with what it read back FOREVER.
68
+ test('tolerates Grafana auto-decorating a panel/target with a datasource it never declared', () => {
69
+ const declared = {
70
+ panels: [{ targets: [{ refId: 'A' }], title: 'CPU' }],
71
+ title: 'Fleet',
72
+ };
73
+ const live = {
74
+ panels: [
75
+ {
76
+ datasource: { type: 'postgres', uid: 'teslamate' },
77
+ targets: [{ datasource: { type: 'postgres', uid: 'teslamate' }, refId: 'A' }],
78
+ title: 'CPU',
79
+ },
80
+ ],
81
+ title: 'Fleet',
82
+ };
83
+ expect(declaredContentMatches(declared, live)).toBe(true);
84
+ });
85
+
86
+ test('still catches a genuine change to a field the declaration DOES mention', () => {
87
+ const declared = { panels: [{ title: 'CPU' }] };
88
+ const live = { panels: [{ datasource: { uid: 'teslamate' }, title: 'Memory' }] };
89
+ expect(declaredContentMatches(declared, live)).toBe(false);
90
+ });
91
+
92
+ test('still fails when live is missing a field the declaration mentions', () => {
93
+ expect(declaredContentMatches({ title: 'Fleet' }, {})).toBe(false);
94
+ });
95
+
96
+ test('still fails on an array length mismatch — panels are not padded away', () => {
97
+ const declared = { panels: [{ title: 'A' }, { title: 'B' }] };
98
+ const live = { panels: [{ title: 'A' }] };
99
+ expect(declaredContentMatches(declared, live)).toBe(false);
100
+ });
101
+
102
+ test('ignores an extra top-level field live carries that the declaration never mentioned', () => {
103
+ expect(declaredContentMatches({ title: 'Fleet' }, { schemaVersion: 39, title: 'Fleet' })).toBe(
104
+ true,
105
+ );
106
+ });
107
+
108
+ test('null (or undefined) on a declared leaf never mismatches, mirroring stripNullish', () => {
109
+ expect(declaredContentMatches({ description: null }, { description: 'set live' })).toBe(true);
110
+ });
111
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Pure functions over a dashboard JSON model — no SDK call, no Effect. Split out of dashboard.ts
3
+ * so that file stays under the house's 250-line cap, and so `normalizeModel`'s no-change-is-noop
4
+ * behavior is exercised directly in dashboard-model.test.ts without a fake Grafana in the way.
5
+ */
6
+ export type DashboardModel = Record<string, unknown>;
7
+
8
+ /** Grafana injects or increments these on every save; a declared model never sets them meaningfully. */
9
+ const VOLATILE_FIELDS = ['id', 'version', 'iteration'] as const;
10
+
11
+ /**
12
+ * ★ NORMALIZES BOTH SIDES THE SAME WAY, so a no-change plan is a true noop
13
+ * (docs/grafana-folder-dashboard.md). Strips `id`/`version`/`iteration` (Grafana-managed, never
14
+ * declared content) and forces `uid` to this resource's OWN canonical value, wherever — or
15
+ * whether — the source object carried one. That second part is "uid placement": a dashboard
16
+ * pasted from Grafana's own "Export as JSON" always embeds the OLD live uid (or none, for a
17
+ * never-saved dashboard), while the live model this resource reads back always carries the
18
+ * CURRENT one; forcing both to `props.uid` before comparing is what makes those two shapes equal
19
+ * instead of permanently disagreeing on one field neither side is meant to declare independently
20
+ * (`DashboardProps.uid` — see dashboard.ts — is the single source of truth).
21
+ *
22
+ * ⚠️ EVERY OTHER DECLARED FIELD COMPARES STRUCTURALLY, ARRAY ORDER INCLUDED — dashboard.ts's
23
+ * `matches` runs this output through THIS FILE's own `declaredContentMatches` (below), which
24
+ * never reorders an array. That is correct here: `panels` is position-significant (screen layout
25
+ * via its own `gridPos`, but the array order also drives Grafana's internal panel indexing and
26
+ * any `repeat` behavior) and each panel's `targets` is execution/legend order. Nothing in a
27
+ * dashboard model is a genuinely unordered SET the way UniFi's `trustedDhcpServerIpAddresses` is
28
+ * (unifi/network-form.ts's `sortedSet`) — this family has no field that needs one.
29
+ * ⚠️ `schemaVersion` IS DELIBERATELY **NOT** NORMALIZED — NOT MEASURED against a live instance (no
30
+ * live calls in this PR). Community reports say Grafana can rewrite an old dashboard's
31
+ * `schemaVersion` upward on load; IF that also happens on every plain save of an
32
+ * already-current-schema dashboard, every plan against it would show a false `update` forever —
33
+ * but silently normalizing it away on a guess would just as easily hide a genuine schema
34
+ * mismatch. Left comparing until measured: add it to `VOLATILE_FIELDS` with a dated, measured
35
+ * comment the day it is seen live, the same standard every other row in this file meets.
36
+ */
37
+ export const normalizeModel = (model: DashboardModel, uid: string): DashboardModel => {
38
+ const normalized: DashboardModel = {};
39
+ for (const [key, value] of Object.entries(model)) {
40
+ if (key === 'uid') continue;
41
+ if ((VOLATILE_FIELDS as readonly string[]).includes(key)) continue;
42
+ normalized[key] = value;
43
+ }
44
+ normalized.uid = uid;
45
+ return normalized;
46
+ };
47
+
48
+ /** The model's own declared title — reporting only. It is never compared on its own; the whole
49
+ * normalized model is, in dashboard.ts's `matches`, so a title-only mismatch still shows `update`. */
50
+ export const modelTitle = (model: DashboardModel): string =>
51
+ typeof model.title === 'string' ? model.title : '';
52
+
53
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
54
+ typeof value === 'object' && value !== null && !Array.isArray(value);
55
+
56
+ /**
57
+ * ★ `declared` NEED ONLY BE A SUBSET OF `live` — an adversarial review of this PR (2026-09-24)
58
+ * found that plain structural equality made an ordinary, never-edited dashboard show `update`
59
+ * FOREVER: Grafana's own dashboard-save schema migration rewrites any panel or target whose
60
+ * `datasource` is absent into an explicit `{type, uid}` reference to the resolved default
61
+ * datasource — documented Grafana schema-migration behavior (not re-measured against a live
62
+ * instance in this PR — no live calls). A declared panel that never sets `datasource` (the
63
+ * common case: hand-authored JSON, or anything relying on the dashboard-level default) would,
64
+ * after the FIRST create, read back with that field populated; plain `deepEqual` (this file used
65
+ * `alchemy/Diff`'s before this fix) then disagreed with the declaration on every subsequent plan,
66
+ * `update` sent the undecorated model back, and Grafana redecorated it again on the next read —
67
+ * non-convergent.
68
+ *
69
+ * This function instead asks only "does every field the DECLARATION mentions, at any depth, still
70
+ * match what is live" — `live` may carry additional fields, at any depth, that `declared` never
71
+ * mentioned (the datasource auto-decoration above, or any future Grafana-injected default,
72
+ * `fieldConfig` defaults and `pluginVersion` included) without being compared at all. A field
73
+ * `declared` DOES mention that is missing, or different, in `live` still fails the match — real
74
+ * content drift is still caught exactly as before; only fields the declaration is silent about are
75
+ * now tolerated rather than compared.
76
+ *
77
+ * ⚠️ THE TRADE-OFF THIS ACCEPTS, DELIBERATELY: removing a field from the declaration (rather than
78
+ * changing its value) can no longer force Grafana to drop it. If a panel once declared an
79
+ * explicit `datasource` and a later declaration deletes that key entirely (rather than setting a
80
+ * different one), `live` keeps whatever the last write set — the declaration is silent about that
81
+ * key now, so nothing compares it, so no `update` ever fires to change it. This is the same
82
+ * "undeclared means don't care, never means force-to-default" convention already used everywhere
83
+ * else in this kit for a plain optional prop (e.g. `datasource.ts`'s `matches` never treats an
84
+ * undeclared `access` as "must be unset"); it now also applies inside the JSON model, not just at
85
+ * the props level. Declare an explicit replacement value to change a field; omission never clears
86
+ * one that was set by a previous declaration or by Grafana itself.
87
+ *
88
+ * ⚠️ ARRAYS COMPARE BY LENGTH, THEN ELEMENT-WISE SUBSET — never by a looser "declared has fewer
89
+ * items" rule. `panels`/`targets` are position-significant (see `normalizeModel`'s header); an
90
+ * array Grafana pads or reorders on its own would still need normalizing here explicitly, the
91
+ * same as any other newly-discovered auto-decoration — this function does not paper over that.
92
+ * ⚠️ `null` ON EITHER SIDE OF A LEAF NEVER MISMATCHES (mirrors `alchemy/Diff`'s `deepEqual` with
93
+ * `stripNullish: true`, which this function replaces for the dashboard model specifically).
94
+ */
95
+ export const declaredContentMatches = (declared: unknown, live: unknown): boolean => {
96
+ if (declared == null) return true;
97
+ if (Array.isArray(declared)) {
98
+ return (
99
+ Array.isArray(live) &&
100
+ declared.length === live.length &&
101
+ declared.every((item, index) => declaredContentMatches(item, live[index]))
102
+ );
103
+ }
104
+ if (isPlainObject(declared)) {
105
+ return (
106
+ isPlainObject(live) &&
107
+ Object.entries(declared).every(([key, value]) => declaredContentMatches(value, live[key]))
108
+ );
109
+ }
110
+ return declared === live;
111
+ };
@@ -0,0 +1,232 @@
1
+ /**
2
+ * `Grafana.Dashboard` against a fake Grafana — the noop-despite-volatile-fields normalization, the
3
+ * provisioned-object refusal, and 412 propagating as a genuine failure rather than being caught or
4
+ * overwritten blindly. Mirrors `datasource.test.ts`/`folder.test.ts`.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import { spec } from './dashboard.ts';
9
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
10
+ import { GrafanaProvisionedObjectError } from './provisioned.ts';
11
+ import { grafanaOperations } from './resource.ts';
12
+
13
+ const DASH_PATH = '/api/dashboards/uid/fleet-overview';
14
+ const CREATE_PATH = '/api/dashboards/db';
15
+
16
+ const model = { panels: [{ id: 1, title: 'CPU' }], title: 'Fleet Overview' };
17
+
18
+ const liveJson = {
19
+ dashboard: { ...model, id: 42, uid: 'fleet-overview', version: 3 },
20
+ meta: {
21
+ folderUid: 'infra',
22
+ provisioned: false,
23
+ provisionedExternalId: '',
24
+ url: '/d/fleet-overview',
25
+ version: 3,
26
+ },
27
+ };
28
+
29
+ const props = { dashboard: model, folderUid: 'infra', uid: 'fleet-overview' };
30
+
31
+ describe('spec.fetchLive', () => {
32
+ test('a live dashboard decodes and normalizes into typed attributes', async () => {
33
+ const fake = fakeGrafana((method, url) =>
34
+ method === 'GET' && url.pathname === DASH_PATH
35
+ ? Response.json(liveJson)
36
+ : fakeFailure(404, 'not found'),
37
+ );
38
+ const live = await Effect.runPromise(
39
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
40
+ );
41
+ expect(live !== undefined && spec.attributes(live, props)).toEqual({
42
+ dashboard: { ...model, uid: 'fleet-overview' },
43
+ folderUid: 'infra',
44
+ provisioned: false,
45
+ provisionedExternalId: '',
46
+ title: 'Fleet Overview',
47
+ uid: 'fleet-overview',
48
+ url: '/d/fleet-overview',
49
+ version: 3,
50
+ });
51
+ expect(fake.bodies[0]).toBeUndefined();
52
+ });
53
+
54
+ test('a 404 folds to undefined', async () => {
55
+ const fake = fakeGrafana(() => fakeFailure(404, 'dashboard not found'));
56
+ const result = await Effect.runPromise(
57
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
58
+ );
59
+ expect(result).toBeUndefined();
60
+ });
61
+ });
62
+
63
+ describe('create', () => {
64
+ test('sends the normalized model and folderUid — no volatile fields, no extra keys', async () => {
65
+ const fake = fakeGrafana((method, url) =>
66
+ method === 'POST' && url.pathname === CREATE_PATH
67
+ ? Response.json({
68
+ folderUid: 'infra',
69
+ id: 42,
70
+ status: 'success',
71
+ title: 'Fleet Overview',
72
+ uid: 'fleet-overview',
73
+ url: '/d/fleet-overview',
74
+ version: 1,
75
+ })
76
+ : fakeFailure(404, 'unexpected request'),
77
+ );
78
+ await Effect.runPromise(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
79
+ expect(fake.bodies[0]).toEqual({
80
+ dashboard: { ...model, uid: 'fleet-overview' },
81
+ folderUid: 'infra',
82
+ });
83
+ });
84
+ });
85
+
86
+ describe('reconcile — noop despite volatile fields', () => {
87
+ test('a declared model matching live content is a noop even though id/version/iteration differ', async () => {
88
+ const fake = fakeGrafana((method, url) =>
89
+ method === 'GET' && url.pathname === DASH_PATH
90
+ ? Response.json(liveJson)
91
+ : fakeFailure(500, 'reconcile should not write when content is unchanged'),
92
+ );
93
+ const after = await Effect.runPromise(
94
+ grafanaOperations(spec)
95
+ .reconcile(props)
96
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
97
+ );
98
+ expect(after.uid).toBe('fleet-overview');
99
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
100
+ });
101
+ });
102
+
103
+ describe('update', () => {
104
+ // ★ ALL THREE TESTS GO THROUGH `grafanaOperations(spec).reconcile`, NOT `spec.update` DIRECTLY —
105
+ // `update` is optional on the shared `GrafanaSpec` type, and `reconcile` is what production
106
+ // code actually calls; it also exercises the SAME `fetchLive` → `matches` → `update` sequence a
107
+ // real plan/deploy does, rather than a hand-assembled `live` argument.
108
+ test('a content change sends the live version for optimistic concurrency, never overwrite', async () => {
109
+ const changed = { ...model, title: 'Fleet Overview v2' };
110
+ let saved = false;
111
+ const fake = fakeGrafana((method, url) => {
112
+ if (method === 'GET' && url.pathname === DASH_PATH) {
113
+ return Response.json(
114
+ saved
115
+ ? {
116
+ dashboard: { ...changed, id: 42, uid: 'fleet-overview', version: 4 },
117
+ meta: { ...liveJson.meta, version: 4 },
118
+ }
119
+ : liveJson,
120
+ );
121
+ }
122
+ if (method === 'POST' && url.pathname === CREATE_PATH) {
123
+ saved = true;
124
+ return Response.json({
125
+ folderUid: 'infra',
126
+ id: 42,
127
+ status: 'success',
128
+ title: 'Fleet Overview v2',
129
+ uid: 'fleet-overview',
130
+ url: '/d/fleet-overview',
131
+ version: 4,
132
+ });
133
+ }
134
+ return fakeFailure(500, 'unexpected request');
135
+ });
136
+ await Effect.runPromise(
137
+ grafanaOperations(spec)
138
+ .reconcile({ ...props, dashboard: changed })
139
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
140
+ );
141
+ const post = fake.seen.findIndex((s) => s.method === 'POST');
142
+ expect(fake.bodies[post]).toMatchObject({
143
+ dashboard: { ...changed, uid: 'fleet-overview', version: 3 },
144
+ });
145
+ // ⛔ `overwrite` IS NEVER SET — Grafana's own version check stays live on every update.
146
+ expect((fake.bodies[post] as { overwrite?: boolean }).overwrite).toBeUndefined();
147
+ });
148
+
149
+ test('a 412 from a concurrent save propagates — never caught, never retried here', async () => {
150
+ const changed = { ...model, title: 'Fleet Overview v2' };
151
+ const fake = fakeGrafana((method, url) => {
152
+ if (method === 'GET' && url.pathname === DASH_PATH) return Response.json(liveJson);
153
+ if (method === 'POST' && url.pathname === CREATE_PATH) {
154
+ return fakeFailure(412, 'The dashboard has been changed by someone else');
155
+ }
156
+ return fakeFailure(500, 'unexpected request');
157
+ });
158
+ const failure = await Effect.runPromise(
159
+ Effect.flip(
160
+ grafanaOperations(spec)
161
+ .reconcile({ ...props, dashboard: changed })
162
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
163
+ ),
164
+ );
165
+ expect(failure._tag).toBe('PreconditionFailed');
166
+ });
167
+
168
+ test('a provisioned dashboard refuses the write, naming its owning file', async () => {
169
+ const provisioned = {
170
+ ...liveJson,
171
+ meta: { ...liveJson.meta, provisioned: true, provisionedExternalId: 'dashboards/fleet.json' },
172
+ };
173
+ const changed = { ...model, title: 'Fleet Overview v2' };
174
+ const fake = fakeGrafana((method, url) =>
175
+ method === 'GET' && url.pathname === DASH_PATH
176
+ ? Response.json(provisioned)
177
+ : fakeFailure(500, 'no write should be attempted'),
178
+ );
179
+ const failure = await Effect.runPromise(
180
+ Effect.flip(
181
+ grafanaOperations(spec)
182
+ .reconcile({ ...props, dashboard: changed })
183
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
184
+ ),
185
+ );
186
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
187
+ expect(failure.message).toContain('dashboards/fleet.json');
188
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
189
+ });
190
+ });
191
+
192
+ describe('destroy', () => {
193
+ test('deletes a live, non-provisioned dashboard by uid', async () => {
194
+ const fake = fakeGrafana((method, url) => {
195
+ if (method === 'GET' && url.pathname === DASH_PATH) return Response.json(liveJson);
196
+ if (method === 'DELETE' && url.pathname === DASH_PATH) {
197
+ return Response.json({ message: 'ok', title: 'Fleet Overview', uid: 'fleet-overview' });
198
+ }
199
+ return fakeFailure(500, 'unexpected request');
200
+ });
201
+ await Effect.runPromise(
202
+ grafanaOperations(spec)
203
+ .destroy(props)
204
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
205
+ );
206
+ expect(fake.seen).toEqual([
207
+ { method: 'GET', path: DASH_PATH },
208
+ { method: 'DELETE', path: DASH_PATH },
209
+ ]);
210
+ });
211
+
212
+ test('a provisioned dashboard refuses destroy and sends no DELETE', async () => {
213
+ const provisioned = {
214
+ ...liveJson,
215
+ meta: { ...liveJson.meta, provisioned: true, provisionedExternalId: 'dashboards/fleet.json' },
216
+ };
217
+ const fake = fakeGrafana((method, url) =>
218
+ method === 'GET' && url.pathname === DASH_PATH
219
+ ? Response.json(provisioned)
220
+ : fakeFailure(500, 'no DELETE should be attempted'),
221
+ );
222
+ const failure = await Effect.runPromise(
223
+ Effect.flip(
224
+ grafanaOperations(spec)
225
+ .destroy(props)
226
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
227
+ ),
228
+ );
229
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
230
+ expect(fake.seen).toEqual([{ method: 'GET', path: DASH_PATH }]);
231
+ });
232
+ });