@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/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
|
|
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)
|
|
154
|
-
`
|
|
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"}
|
package/dist/grafana/index.d.ts
CHANGED
|
@@ -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,
|
|
6
|
-
* are internals a provider needs but a consumer should not
|
|
5
|
+
* the symbols a real stack consumes; the rest of the files (resource.ts's engine, provisioned.ts,
|
|
6
|
+
* dashboard-model.ts, fake-grafana.ts) are internals a provider needs but a consumer should not
|
|
7
|
+
* depend on.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* docs/upstream-conformance.md recorded is fixed. Building `Grafana.Folder`, `Grafana.Dashboard`,
|
|
12
|
-
* `Grafana.AlertRule` etc. on top of them is follow-up work this PR deliberately does not do —
|
|
13
|
-
* see docs/grafana.md for what changed and what is still open.
|
|
9
|
+
* `Grafana.Datasource`, `Grafana.Folder` and `Grafana.Dashboard` ship here. `Grafana.AlertRule`
|
|
10
|
+
* and friends are still follow-up work on the same now-available SDK operations — see
|
|
11
|
+
* docs/grafana.md and docs/grafana-folder-dashboard.md for what shipped and what is still open.
|
|
14
12
|
*/
|
|
15
13
|
export { 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
|
|
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"}
|
package/dist/grafana/index.js
CHANGED
|
@@ -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
|
|
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) =>
|
|
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
|
-
})) :
|
|
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
|
-
})),
|
|
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=
|
|
300
|
+
//# debugId=D031E5F2BA23E8BC64756E2164756E21
|
|
149
301
|
//# sourceMappingURL=index.js.map
|