@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.
- package/README.md +39 -3
- package/dist/grafana/dashboard-model.d.ts +77 -0
- package/dist/grafana/dashboard-model.d.ts.map +1 -0
- package/dist/grafana/dashboard.d.ts +98 -0
- package/dist/grafana/dashboard.d.ts.map +1 -0
- package/dist/grafana/folder.d.ts +100 -0
- package/dist/grafana/folder.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +9 -8
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +158 -6
- package/dist/grafana/index.js.map +8 -4
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/provisioned.d.ts +11 -0
- package/dist/grafana/provisioned.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +129 -0
- package/dist/linux/container-form.d.ts.map +1 -0
- package/dist/linux/container-generator.d.ts +74 -0
- package/dist/linux/container-generator.d.ts.map +1 -0
- package/dist/linux/container-handlers.d.ts +15 -0
- package/dist/linux/container-handlers.d.ts.map +1 -0
- package/dist/linux/container-lifecycle.d.ts +37 -0
- package/dist/linux/container-lifecycle.d.ts.map +1 -0
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -0
- package/dist/linux/container-secrets.d.ts +37 -0
- package/dist/linux/container-secrets.d.ts.map +1 -0
- package/dist/linux/container-settle.d.ts +34 -0
- package/dist/linux/container-settle.d.ts.map +1 -0
- package/dist/linux/container.d.ts +30 -0
- package/dist/linux/container.d.ts.map +1 -0
- package/dist/linux/fake-quadlet.d.ts +25 -0
- package/dist/linux/fake-quadlet.d.ts.map +1 -0
- package/dist/linux/index.d.ts +6 -3
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +641 -268
- package/dist/linux/index.js.map +15 -7
- package/dist/linux/providers.d.ts +1 -1
- package/dist/linux/providers.d.ts.map +1 -1
- package/dist/linux/systemctl.d.ts +7 -1
- package/dist/linux/systemctl.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +29 -0
- package/dist/opnsense/alias-form.d.ts.map +1 -0
- package/dist/opnsense/alias.d.ts +81 -0
- package/dist/opnsense/alias.d.ts.map +1 -0
- package/dist/opnsense/category-form.d.ts +10 -0
- package/dist/opnsense/category-form.d.ts.map +1 -0
- package/dist/opnsense/category.d.ts +58 -0
- package/dist/opnsense/category.d.ts.map +1 -0
- package/dist/opnsense/credentials.d.ts +36 -0
- package/dist/opnsense/credentials.d.ts.map +1 -0
- package/dist/opnsense/fake-opnsense.d.ts +51 -0
- package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
- package/dist/opnsense/group-form.d.ts +11 -0
- package/dist/opnsense/group-form.d.ts.map +1 -0
- package/dist/opnsense/group.d.ts +62 -0
- package/dist/opnsense/group.d.ts.map +1 -0
- package/dist/opnsense/index.d.ts +16 -0
- package/dist/opnsense/index.d.ts.map +1 -0
- package/dist/opnsense/index.js +211 -0
- package/dist/opnsense/index.js.map +20 -0
- package/dist/opnsense/policy.d.ts +20 -0
- package/dist/opnsense/policy.d.ts.map +1 -0
- package/dist/opnsense/providers.d.ts +13 -0
- package/dist/opnsense/providers.d.ts.map +1 -0
- package/dist/opnsense/resource.d.ts +74 -0
- package/dist/opnsense/resource.d.ts.map +1 -0
- package/dist/opnsense/wire.d.ts +39 -0
- package/dist/opnsense/wire.d.ts.map +1 -0
- package/dist/proxmox/index.js +170 -80
- package/dist/proxmox/index.js.map +9 -7
- package/dist/proxmox/storage-form.d.ts +54 -0
- package/dist/proxmox/storage-form.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +56 -0
- package/dist/proxmox/storage-wire.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +55 -60
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-form.d.ts +124 -0
- package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
- package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool.d.ts +27 -52
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +48 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -0
- package/dist/unifi/firewall-zone-form.d.ts +40 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
- package/dist/unifi/firewall-zone.d.ts +50 -0
- package/dist/unifi/firewall-zone.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +13 -0
- package/dist/unifi/index.d.ts.map +1 -0
- package/dist/unifi/index.js +190 -0
- package/dist/unifi/index.js.map +16 -0
- package/dist/unifi/network-form.d.ts +67 -0
- package/dist/unifi/network-form.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +67 -0
- package/dist/unifi/network.d.ts.map +1 -0
- package/dist/unifi/policy.d.ts +20 -0
- package/dist/unifi/policy.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +13 -0
- package/dist/unifi/providers.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +64 -0
- package/dist/unifi/resource.d.ts.map +1 -0
- package/docs/grafana-folder-dashboard.md +145 -0
- package/docs/grafana.md +11 -10
- package/docs/opnsense.md +146 -0
- package/docs/provider-standard.md +1 -1
- package/docs/quadlet-container.md +132 -0
- package/docs/unifi.md +93 -0
- package/docs/upstream-conformance.md +9 -4
- package/package.json +12 -2
- package/src/grafana/dashboard-model.test.ts +111 -0
- package/src/grafana/dashboard-model.ts +111 -0
- package/src/grafana/dashboard.test.ts +232 -0
- package/src/grafana/dashboard.ts +161 -0
- package/src/grafana/folder.test.ts +212 -0
- package/src/grafana/folder.ts +161 -0
- package/src/grafana/index.ts +21 -8
- package/src/grafana/providers.ts +7 -1
- package/src/grafana/provisioned.ts +49 -0
- package/src/linux/container-fixture.test.ts +92 -0
- package/src/linux/container-form.ts +243 -0
- package/src/linux/container-generator.test.ts +195 -0
- package/src/linux/container-generator.ts +113 -0
- package/src/linux/container-handlers.ts +73 -0
- package/src/linux/container-lifecycle.test.ts +209 -0
- package/src/linux/container-lifecycle.ts +217 -0
- package/src/linux/container-preflight.ts +97 -0
- package/src/linux/container-secrets.test.ts +171 -0
- package/src/linux/container-secrets.ts +112 -0
- package/src/linux/container-settle.ts +97 -0
- package/src/linux/container.ts +60 -0
- package/src/linux/fake-quadlet.ts +127 -0
- package/src/linux/index.ts +11 -3
- package/src/linux/providers.ts +2 -0
- package/src/linux/systemctl.ts +9 -0
- package/src/opnsense/alias-form.test.ts +89 -0
- package/src/opnsense/alias-form.ts +53 -0
- package/src/opnsense/alias.test.ts +203 -0
- package/src/opnsense/alias.ts +117 -0
- package/src/opnsense/category-form.test.ts +54 -0
- package/src/opnsense/category-form.ts +20 -0
- package/src/opnsense/category.test.ts +138 -0
- package/src/opnsense/category.ts +82 -0
- package/src/opnsense/credentials.ts +41 -0
- package/src/opnsense/fake-opnsense.ts +79 -0
- package/src/opnsense/group-form.test.ts +75 -0
- package/src/opnsense/group-form.ts +25 -0
- package/src/opnsense/group.test.ts +141 -0
- package/src/opnsense/group.ts +78 -0
- package/src/opnsense/index.ts +45 -0
- package/src/opnsense/policy.ts +49 -0
- package/src/opnsense/providers.ts +21 -0
- package/src/opnsense/resource.ts +146 -0
- package/src/opnsense/wire.ts +56 -0
- package/src/opnsense/write-refusal.test.ts +148 -0
- package/src/proxmox/constraints-host-forms.test.ts +1 -1
- package/src/proxmox/storage-form.ts +144 -0
- package/src/proxmox/storage-read-failure.test.ts +214 -0
- package/src/proxmox/storage-wire.ts +124 -0
- package/src/proxmox/storage.ts +165 -175
- package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
- package/src/proxmox/zfs-pool-form.ts +236 -0
- package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
- package/src/proxmox/zfs-pool-wire.ts +132 -0
- package/src/proxmox/zfs-pool.ts +106 -76
- package/src/unifi/fake-unifi.ts +60 -0
- package/src/unifi/firewall-zone-form.ts +65 -0
- package/src/unifi/firewall-zone.test.ts +88 -0
- package/src/unifi/firewall-zone.ts +66 -0
- package/src/unifi/index.ts +26 -0
- package/src/unifi/network-form.ts +207 -0
- package/src/unifi/network.test.ts +209 -0
- package/src/unifi/network.ts +83 -0
- package/src/unifi/policy.ts +37 -0
- package/src/unifi/providers.ts +15 -0
- package/src/unifi/resource.test.ts +133 -0
- package/src/unifi/resource.ts +131 -0
- package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
- package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
- package/dist/proxmox/zfs-pool-write.d.ts +0 -128
- package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
- package/src/proxmox/zfs-pool-spec.ts +0 -76
- package/src/proxmox/zfs-pool-write.ts +0 -196
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).
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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.
|
|
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
|
+
});
|