@homeflare/alchemy 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/grafana/dashboard-model.d.ts +77 -0
  2. package/dist/grafana/dashboard-model.d.ts.map +1 -0
  3. package/dist/grafana/dashboard.d.ts +98 -0
  4. package/dist/grafana/dashboard.d.ts.map +1 -0
  5. package/dist/grafana/folder.d.ts +100 -0
  6. package/dist/grafana/folder.d.ts.map +1 -0
  7. package/dist/grafana/index.d.ts +9 -8
  8. package/dist/grafana/index.d.ts.map +1 -1
  9. package/dist/grafana/index.js +158 -6
  10. package/dist/grafana/index.js.map +8 -4
  11. package/dist/grafana/providers.d.ts +1 -1
  12. package/dist/grafana/providers.d.ts.map +1 -1
  13. package/dist/grafana/provisioned.d.ts +11 -0
  14. package/dist/grafana/provisioned.d.ts.map +1 -0
  15. package/dist/proxmox/index.js +76 -45
  16. package/dist/proxmox/index.js.map +6 -6
  17. package/dist/proxmox/zfs-pool-form.d.ts +124 -0
  18. package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
  19. package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
  20. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
  21. package/dist/proxmox/zfs-pool.d.ts +27 -52
  22. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  23. package/docs/grafana-folder-dashboard.md +145 -0
  24. package/docs/grafana.md +11 -10
  25. package/docs/upstream-conformance.md +9 -4
  26. package/package.json +1 -1
  27. package/src/grafana/dashboard-model.test.ts +111 -0
  28. package/src/grafana/dashboard-model.ts +111 -0
  29. package/src/grafana/dashboard.test.ts +232 -0
  30. package/src/grafana/dashboard.ts +161 -0
  31. package/src/grafana/folder.test.ts +212 -0
  32. package/src/grafana/folder.ts +161 -0
  33. package/src/grafana/index.ts +21 -8
  34. package/src/grafana/providers.ts +7 -1
  35. package/src/grafana/provisioned.ts +49 -0
  36. package/src/proxmox/constraints-host-forms.test.ts +1 -1
  37. package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
  38. package/src/proxmox/zfs-pool-form.ts +236 -0
  39. package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
  40. package/src/proxmox/zfs-pool-wire.ts +132 -0
  41. package/src/proxmox/zfs-pool.ts +106 -76
  42. package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
  43. package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
  44. package/dist/proxmox/zfs-pool-write.d.ts +0 -128
  45. package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
  46. package/src/proxmox/zfs-pool-spec.ts +0 -76
  47. package/src/proxmox/zfs-pool-write.ts +0 -196
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The create lane for `Proxmox.ZfsPool`: the form PVE wants, and the wait for the worker it forks.
3
+ * Split out of zfs-pool.ts (2026-09-24, the distilled migration) — same seam as before, now naming
4
+ * the write side the way storage.ts/storage-form.ts does.
5
+ *
6
+ * ⚠️ `createPool` TAKES THE READ AS A PARAMETER, GENERIC IN ITS ERROR AND REQUIREMENT CHANNELS,
7
+ * rather than importing the read module and coupling to its exact effect shape here. This file
8
+ * answers "how does a declaration become a pool on disks"; zfs-pool-wire.ts answers "what is a
9
+ * zpool, and when has it changed" — the caller (zfs-pool.ts) is what ties the two together, so
10
+ * this file cannot accidentally read the cluster differently from `diff`.
11
+ */
12
+ import * as Effect from 'effect/Effect';
13
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
14
+ import type { ZfsPoolAttributes, ZfsPoolProps } from './zfs-pool.ts';
15
+ import type { NodesNodeDisksZfsPostParams } from './generated/pve.ts';
16
+ export declare const ZFS_POOL_CREATE = "pve:POST /nodes/{node}/disks/zfs";
17
+ /**
18
+ * PVE's layouts, generated (2026-09-23) from `NodesNodeDisksZfsPostParams['raidlevel']`
19
+ * (`generated/pve.ts`, manifest `pve-apidoc` pve-manager 9.2.11 sha256 9def8f13…) rather than
20
+ * hand-typed, per decision 9. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an
21
+ * even one. ⛔ THERE IS NO `stripe` IN THIS ENUM. n1's `speed` pool is one (decision 14 accepts it
22
+ * as-is): `ZfsPoolProps` (zfs-pool.ts) is why a stripe pool is declarable at all — as an
23
+ * ADOPT-ONLY declaration with no `raidlevel`, never as a full one.
24
+ */
25
+ export type ZfsRaidLevel = NonNullable<NodesNodeDisksZfsPostParams['raidlevel']>;
26
+ /** Generated the same way, from `NodesNodeDisksZfsPostParams['compression']`. */
27
+ export type ZfsCompression = NonNullable<NodesNodeDisksZfsPostParams['compression']>;
28
+ /**
29
+ * ★ CHECKED AGAINST THE VENDOR TABLE ONLY FOR A FULL DECLARATION — decision 9's adopt-only mode.
30
+ * `guardCreate` (distilled-guard.ts's `guardWrite`) runs on the FIRST plan of ANY declaration and
31
+ * checks the create form's REQUIRED fields whenever this resolves to the real endpoint key — and
32
+ * PVE marks both `devices` and `raidlevel` required (codegen/constraints's `pve-nodes-disks`
33
+ * row). An adopt-only declaration deliberately omits both, so checking it would refuse a MATCHING
34
+ * adoption at plan time, before `read` ever ran. Returning `undefined` (no key) makes the guard a
35
+ * no-op for an adopt-only declaration, while a FULL one (`devices` and `raidlevel` both present)
36
+ * is checked exactly as before — zfs-pool.ts's `diff` passes this as the `guardWrite` endpoint.
37
+ * ⚠️ THE RUNTIME BACKSTOP IS `createPool` BELOW, NOT THIS FUNCTION. Skipping the plan-time guard
38
+ * for an adopt-only declaration does not skip the check that the pool is actually there —
39
+ * `createPool` refuses before any POST when the read comes back absent and either field is
40
+ * undefined, which is what a genuinely absent adopt-only declaration hits at apply time.
41
+ */
42
+ export declare const zfsPoolCreateEndpoint: (props: ZfsPoolProps) => "pve:POST /nodes/{node}/disks/zfs" | undefined;
43
+ /**
44
+ * The wire form, PVE's own field names — what `guardWrite` (zfs-pool.ts) checks.
45
+ *
46
+ * ⚠️ `devices` IS JOINED IN DECLARED ORDER AND NOT PUT THROUGH `csv()` FROM values.ts. `csv` sorts,
47
+ * and PVE walks this list two at a time to pair `raid10` mirrors — MEASURED in the node's own
48
+ * `PVE::API2::Disks::ZFS` — so sorting it builds a different pool from the one declared. The list
49
+ * is never compared against anything, so it needs no normalisation, only faithful order.
50
+ * ⚠️ AN OMITTED OPTIONAL IS NOT SENT. PVE's own defaults (ashift 12, compression on) are then
51
+ * applied by the node. Sending a guessed default would be indistinguishable here — the values
52
+ * are unreadable afterwards either way — but it would put a number in the request that no
53
+ * declaration asked for, and this is the one call that writes to physical disks.
54
+ * ⚠️ `node` IS NOT IN THE FORM: it is the `nodes/{node}` path label, and a second copy could only
55
+ * disagree with it.
56
+ * ⚠️ EVERY KEY IS OMITTED WHEN ABSENT, `devices` AND `raidlevel` INCLUDED — not just the optional
57
+ * ones. An adopt-only declaration (both undefined, decision 9) must produce a form `createPool`'s
58
+ * own guard can inspect for their absence, and one this function itself does not throw building.
59
+ */
60
+ export declare const createForm: (props: ZfsPoolProps) => Record<string, string>;
61
+ /**
62
+ * Create the pool if it is absent, and otherwise leave it completely alone.
63
+ *
64
+ * ⛔ THE POST ONLY FORKS A WORKER. MEASURED in `/usr/share/perl5/PVE/API2/Disks/ZFS.pm` on node-b:
65
+ * the create handler ends in `$rpcenv->fork_worker('zfscreate', ...)` and its HTTP answer is a
66
+ * UPID returned the instant the worker is forked — before `zpool create` has run, let alone
67
+ * finished. A read-back taken immediately would report a successful create as the failure "the
68
+ * write returned no error but the object is still absent". THAT is why this function polls.
69
+ *
70
+ * ⚠️ THERE IS NO UPDATE BRANCH BECAUSE PVE HAS NO PUT HERE. An existing pool is returned exactly as
71
+ * read, unwritten: the only write this resource ever makes is the create of a pool that is not
72
+ * there. Do not add a "repair" write; there is no field it could set.
73
+ *
74
+ * ⛔ AND IF THE READ IS WRONG, THE POST IS STILL SAFE — measured, not hoped for. `readPool` folds a
75
+ * 403 or an unreachable node into "absent", so a too-narrow lease would send this down the create
76
+ * path over a live pool. PVE's handler calls `get_pool_data()` before it forks anything and dies
77
+ * with "pool '<name>' already exists on node '<node>'", and `assert_disk_unused` refuses every
78
+ * device that pool is holding. A create aimed at a pool that is really there fails loudly rather
79
+ * than wiping it.
80
+ *
81
+ * ⚠️ SHORT POLLS WITH A HARD CAP, NOT ONE LONG WAIT, NOT `@distilled.cloud/proxmox`'s `awaitTask`.
82
+ * `readPool` cannot tell "not yet" from "forbidden" — both fold to `undefined` — so the loop
83
+ * gives up with the UPID rather than retrying forever, and the UPID is the only thing that leads
84
+ * to the worker's real error. Distilled's own task-status poller exists (`Task.ts`) and would
85
+ * remove this ambiguity, but swapping to it changes this resource's OBSERVABLE behaviour (what a
86
+ * caller sees on a slow create) beyond a transport swap — left as a follow-up, not folded into a
87
+ * migration PR silently.
88
+ *
89
+ * ⛔ THE SAME AMBIGUITY MAKES A RE-RUN AFTER THE GIVE-UP DIE GENUINELY UNSAFE, NOT MERELY SLOW TO
90
+ * CONFIRM — FOUND BY ADVERSARIAL REVIEW, 2026-09-24, of THIS migration, though the shape is
91
+ * unchanged from before it (`git blame` shows the same settle-poll pre-dates this PR). "If the
92
+ * read is wrong, the POST is still safe" above is true for the FIRST call into this function —
93
+ * PVE's own `get_pool_data()` refuses a create over a pool it can already see. It is NOT true
94
+ * for a SECOND deploy attempt started while the FIRST worker is still mid-`zpool create`: that
95
+ * worker has not yet made the pool visible to a read, so `existing = yield* read(props)` at the
96
+ * top of THIS call can also fold to `undefined` — not because the pool is gone, but because the
97
+ * first attempt has not finished — and this function would POST a SECOND `zpool create` at the
98
+ * SAME devices while the first is still writing them. There is no lock or marker anywhere in
99
+ * this package that would stop it. The give-up message below says so explicitly and names the
100
+ * task log to check FIRST; that message, and an operator reading it, are the only safeguard.
101
+ *
102
+ * ⛔ THE ADOPT-ONLY GUARD, RIGHT AFTER THE READ AND BEFORE ANYTHING ELSE. Decision 9 (2026-09-23):
103
+ * omitting `devices` and `raidlevel` declares a pool this resource may only ADOPT, for a pool
104
+ * PVE's own schema cannot fully describe — n1's `speed` is a stripe, and `raidlevel`'s enum has
105
+ * no stripe (zfs-pool.ts). `zfsPoolCreateEndpoint` above already keeps the vendor constraint
106
+ * guard from refusing such a declaration at PLAN time; this is the matching refusal at APPLY
107
+ * time, for the one case that guard cannot see — the pool genuinely is not there.
108
+ */
109
+ export declare const createPool: <E, R>(props: ZfsPoolProps, read: (props: ZfsPoolProps) => Effect.Effect<ZfsPoolAttributes | undefined, E, R>) => Effect.Effect<ZfsPoolAttributes, E | Error | import("effect/unstable/http/HttpClientError").HttpClientError | import("./credential-errors.ts").PveCredentialDenied, R | import("effect/unstable/http/HttpClient").HttpClient>;
110
+ /**
111
+ * ⛔ `DELETE /nodes/{node}/disks/zfs/{name}` RUNS `zpool destroy` AND THE DATASETS GO WITH IT. Per
112
+ * S14 (alchemy-provider-standard), it is implemented rather than stubbed — a `delete` that
113
+ * silently does nothing lies to whoever reads the plan — and what keeps an ordinary removed
114
+ * declaration from reaching it is the resource's `defaultRemovalPolicy: 'retain'` (zfs-pool.ts):
115
+ * an orphaned pool is forgotten, not destroyed, unless a caller opts in with
116
+ * `.pipe(RemovalPolicy.destroy())`. See the corrected header in zfs-pool.ts.
117
+ *
118
+ * ⚠️ `cleanup-config`/`cleanup-disks` ARE NOT SENT. The first would additionally remove the PVE
119
+ * storage entry pointing at this pool, which is a different object with its own resource
120
+ * (`Proxmox.Storage`); one resource reaching over to delete another's is how a stack ends up with
121
+ * state describing something that is gone. Neither is offered as a prop, for the same reason.
122
+ */
123
+ export declare const destroyPool: (props: ZfsPoolProps) => Effect.Effect<string, Error | import("./distilled-pve.ts").PveClusterExhausted | import("./credential-errors.ts").PveCredentialDenied | import("effect/Cause").TimeoutError | nodes.ProxmoxOpError, import("effect/unstable/http/HttpClient").HttpClient>;
124
+ //# sourceMappingURL=zfs-pool-form.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zfs-pool-form.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-form.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,MAAM,gCAAgC,CAAC;AAKxD,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AACrE,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,oBAAoB,CAAC;AAEtE,eAAO,MAAM,eAAe,qCAAqC,CAAC;AAElE;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG,WAAW,CAAC,2BAA2B,CAAC,WAAW,CAAC,CAAC,CAAC;AAEjF,iFAAiF;AACjF,MAAM,MAAM,cAAc,GAAG,WAAW,CAAC,2BAA2B,CAAC,aAAa,CAAC,CAAC,CAAC;AAErF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,qBAAqB,UAAW,YAAY,mDACmC,CAAC;AAE7F;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,UAAU,UAAW,YAAY,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAOpE,CAAC;AA0BH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,CAAC,SACtB,YAAY,QACb,CAAC,KAAK,EAAE,YAAY,KAAK,MAAM,CAAC,MAAM,CAAC,iBAAiB,GAAG,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC,kOA8D/E,CAAC;AAEL;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,UAAW,YAAY,8PAM5C,CAAC"}
@@ -0,0 +1,78 @@
1
+ import * as Effect from 'effect/Effect';
2
+ import type { ZfsPoolProps } from './zfs-pool.ts';
3
+ import { UNREADABLE, type Unreadable } from './unreadable-read.ts';
4
+ /**
5
+ * ⚠️ EVERYTHING HERE IS REPORTED, NOTHING HERE IS COMPARED — see the ⛔ on `matches` below. These
6
+ * exist so a plan and the state store can say what the pool actually is, on a family where the
7
+ * declaration and the live object share no comparable field at all.
8
+ */
9
+ export interface ZfsPoolAttributes {
10
+ /** ★ The value a `Proxmox.Storage` should read to order itself after this pool. */
11
+ name: string;
12
+ node: string;
13
+ /** `ONLINE` | `DEGRADED` | `FAULTED` | … Health, not configuration. */
14
+ state: string;
15
+ /** ZFS's own error summary, e.g. `No known data errors`. */
16
+ errors: string;
17
+ /**
18
+ * The leaf devices ZFS reports, in vdev order, comma-joined.
19
+ *
20
+ * ⚠️ THIS IS WHAT ZFS RESOLVED, NOT WHAT WAS DECLARED, and the two normally differ: PVE rewrites
21
+ * a `/dev/sdb` into a by-id link before creating, and ZFS reports partition paths (`…-part3`)
22
+ * for a pool built on partitions — MEASURED, that is exactly what node-b's `rpool` reports.
23
+ * Never compare it with `props.devices`.
24
+ */
25
+ devices: string;
26
+ }
27
+ /**
28
+ * The device paths at the bottom of PVE's vdev tree, in the order ZFS reports them.
29
+ *
30
+ * ⚠️ "NO CHILDREN" IS THE LEAF TEST, AND IT IS NOT A GUESS — MEASURED in the node's `preparetree`,
31
+ * which sets `leaf` to 0 when there are children and 1 otherwise. Reading the flag would work
32
+ * equally well; recursing on `children` needs no second field to be present and is what makes
33
+ * nested sections (mirrors inside a raid10, `spares`, `cache`) flatten correctly.
34
+ */
35
+ export declare const leafDevices: (children: unknown) => string[];
36
+ /**
37
+ * ★ Default `read` role — `Sys.Audit` on `/` is all this GET checks (zfs-pool.ts's header).
38
+ * ⛔ `{ node: props.node, name: props.name }`, NEVER THE WHOLE `props` — storage.ts's/user.ts's own
39
+ * ⛔, the kit 0.31.1 regression: `GetNodeDiskZfsRequest`'s schema declares only `node` and `name`
40
+ * (both path labels); any OTHER key on the object passed to `nodes.getNodeDiskZfs` is treated by
41
+ * distilled's `buildRequest` as an "unknown key" and JSON-encoded onto this bodyless GET, which a
42
+ * stricter fetch client refuses outright.
43
+ * ⛔ A MISSING POOL IS A 500, NOT A 404 — MEASURED against the live cluster (TB4 n2, 2026-09-24,
44
+ * read-role, read-only probe): `GET /nodes/n2/disks/zfs/hf-measure-nonexistent-pool` answers
45
+ * `{"message":"command '/sbin/zpool status -P hf-measure-nonexistent-pool' failed: exit code
46
+ * 1\n","data":null}` at HTTP 500 — PVE shells out to `zpool status` and reports its exit code
47
+ * as a generic command failure, not a typed "not found". The same shape as user.ts's/group.ts's/
48
+ * storage.ts's own measured 500s. `readPool` below (used by `createPool`'s settle-poll and by
49
+ * `reconcile`/`read`) still folds it, exactly as `readUser`/`readGroup`/`readStorage` do — the
50
+ * whole reason `createPool` can tell "not built yet" from "forbidden" only by giving up after
51
+ * `SETTLE_ATTEMPTS` (zfs-pool-form.ts's own ⚠️ on that); `readPoolOrFail` (used by `diff`) does
52
+ * not fold it, so a genuine transient failure there propagates and fails the plan loudly instead.
53
+ */
54
+ export declare const readPoolOrFail: (props: ZfsPoolProps) => Effect.Effect<typeof UNREADABLE | ZfsPoolAttributes | undefined, import("@distilled.cloud/caddy").BadGateway | import("@distilled.cloud/caddy").BadRequest | import("@distilled.cloud/caddy").ConfigError | Error | import("@distilled.cloud/caddy").GatewayTimeout | import("effect/unstable/http/HttpClientError").HttpClientError | import("@distilled.cloud/caddy").InternalServerError | import("@distilled.cloud/proxmox/Errors").ParameterVerificationFailed | import("@distilled.cloud/proxmox/Errors").ProxmoxParseError | import("./distilled-pve.ts").PveClusterExhausted | import("@distilled.cloud/caddy").ServiceUnavailable | import("effect/Cause").TimeoutError | import("@distilled.cloud/caddy").TooManyRequests | import("@distilled.cloud/caddy").Unauthorized | import("@distilled.cloud/proxmox/Errors").UnknownProxmoxError, import("effect/unstable/http/HttpClient").HttpClient>;
55
+ /**
56
+ * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `createPool`'s settle-poll AND
57
+ * `read`/`reconcile` MAY USE IT — user.ts's `readUser` has the full reasoning. Safe here because
58
+ * a wrongful fold during create costs at most a redundant `createNodeDiskZfs` POST, which PVE
59
+ * refuses loudly ("pool already exists") rather than silently corrupting anything — zfs-pool.ts's
60
+ * own header, measured.
61
+ * ⛔ `zfs-pool.ts`'s `diff` does **NOT** use this — the same bug class as the credential denial fix:
62
+ * folding a TRANSIENT failure into "absent" at PLAN TIME would force a false `update` with
63
+ * nothing compared. `diff` calls `readPoolOrFail` directly, so a genuine failure there propagates
64
+ * and fails the whole plan loudly instead.
65
+ */
66
+ export declare const readPool: (props: ZfsPoolProps) => Effect.Effect<typeof UNREADABLE | ZfsPoolAttributes | undefined, never, import("effect/unstable/http/HttpClient").HttpClient>;
67
+ /** `read`/`reconcile`/`createPool` return `Attributes | undefined`; only `diff` sees `UNREADABLE`. */
68
+ export declare const dropUnreadable: (live: ZfsPoolAttributes | Unreadable | undefined) => ZfsPoolAttributes | undefined;
69
+ /**
70
+ * ⛔ THERE IS NO `matches` FUNCTION HERE — UNLIKE EVERY OTHER MIGRATED FAMILY, AND DELIBERATELY.
71
+ * Every create parameter on this family is write-only (this file's own `attributesOf`), so
72
+ * there is nothing a declaration and a live pool can ever be compared on — a `matches` that
73
+ * invented one would plan a replace (`zpool destroy`, since this family has no PUT) over a
74
+ * difference it could never verify. `zfs-pool.ts`'s `diff` returns `noop` unconditionally once a
75
+ * pool is found, rather than calling a function that would only ever return `true` — found by
76
+ * adversarial review that an earlier draft exported one here anyway, unused by anything.
77
+ */
78
+ //# sourceMappingURL=zfs-pool-wire.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zfs-pool-wire.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-wire.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAoB,MAAM,sBAAsB,CAAC;AAGrF;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,mFAAmF;IACnF,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,aAAc,OAAO,KAAG,MAAM,EAO9C,CAAC;AAmBT;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,cAAc,UAAW,YAAY,+2BAe/C,CAAC;AAEJ;;;;;;;;;;GAUG;AACH,eAAO,MAAM,QAAQ,UAAW,YAAY,kIACuB,CAAC;AAEpE,sGAAsG;AACtG,eAAO,MAAM,cAAc,SAAU,iBAAiB,GAAG,UAAU,GAAG,SAAS,kCACvC,CAAC;AAEzC;;;;;;;;GAQG"}
@@ -26,14 +26,14 @@
26
26
  * ⛔ THERE IS NO PUT ON THIS FAMILY AT ALL. MEASURED from the cluster's own published schema
27
27
  * (`/usr/share/pve-docs/api-viewer/apidoc.js`, read on node-b 2026-09-13): `/nodes/{node}/disks/zfs`
28
28
  * has GET and POST, `/nodes/{node}/disks/zfs/{name}` has GET and DELETE, and that is the whole
29
- * surface. So `updateForm` is omitted — which, per `resource.ts`, makes any `matches` = false a
30
- * REPLACE. A replace is a delete followed by a create, and on this family the delete is the
31
- * thing above. Read the ⛔ on `matches` before adding anything to it.
29
+ * surface — CONFIRMED again in `@distilled.cloud/proxmox`'s generated `nodes.ts`, which declares
30
+ * no `putNodeDiskZfs` at all. So `matches` (zfs-pool-wire.ts) always answers `true`: there is
31
+ * nothing to plan a replace over, and the family's one write is the create below.
32
32
  *
33
33
  * ⛔ NOT ONE CREATE PARAMETER COMES BACK ON READ, SO `matches` COMPARES NOTHING. MEASURED against
34
- * the live cluster: `GET /nodes/node-b/disks/zfs/rpool` answers exactly
35
- * `{action, children, errors, leaf, name, scan, state, status}` — no `ashift`, no `compression`,
36
- * no `raidlevel`, no `devices`, no `add_storage`. Every create parameter is write-only. There is
34
+ * the live cluster: `GET /nodes/n2/disks/zfs/rpool` answers exactly
35
+ * `{action?, children, errors, name, scan?, state, status?}` — no `ashift`, no `compression`, no
36
+ * `raidlevel`, no `devices`, no `add_storage`. Every create parameter is write-only. There is
37
37
  * therefore no field a declaration and a live pool can both be asked about, and a `matches` that
38
38
  * invented one would plan a replace — i.e. a `zpool destroy` — over a difference it could never
39
39
  * verify in the first place.
@@ -46,11 +46,20 @@
46
46
  * is compared, and the three churning strings are kept out of the state store entirely rather
47
47
  * than rewriting this resource's row every time somebody writes a file.
48
48
  *
49
- * ⚠️ TWO HANDLERS ARE OVERRIDDEN AND THE OTHER THREE COME FROM THE FACTORY — see the ★ above
50
- * `handlers`. `delete` is the ⛔ above. `reconcile` is because the create is a FORKED WORKER, so
51
- * the factory's immediate read-back would report a successful create as a failure; the measured
52
- * detail, and the adopt-only refusal decision 9 adds, are both in `createPool` in
53
- * zfs-pool-write.ts.
49
+ * ⛔ A MISSING POOL IS A 500, NOT A 404 — MEASURED against the live cluster (TB4 n2, 2026-09-24,
50
+ * read-role, read-only probe): `GET /nodes/n2/disks/zfs/hf-measure-nonexistent-pool` answers a
51
+ * generic shelled-out command failure at HTTP 500, the same shape as every other family this
52
+ * package has migrated. `zfs-pool-wire.ts` carries the same dual-path read PR 239/243
53
+ * established: `readPoolOrFail` (non-folding, used by `diff`) and `readPool` (folding, used by
54
+ * `createPool`'s settle-poll and by `read`/`reconcile`).
55
+ *
56
+ * ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveOperations` ONTO `@distilled.cloud/proxmox`'s
57
+ * typed `nodes.getNodeDiskZfs`/`createNodeDiskZfs`/`deleteNodeDiskZfs` (2026-09-24, decision
58
+ * 43's proxmox walk-down, nodes/storage sub-area). `distilled-pve.ts`'s `runPve` replaces
59
+ * `pve()`; the cries-wolf fix is wired the same way as every other migrated family. `reconcile`
60
+ * and `delete` are still hand-written (never the factory) for the SAME two reasons as before the
61
+ * migration — the forked-worker settle-poll, and a DELETE that destroys data — now calling
62
+ * `zfs-pool-form.ts`'s `createPool`/`destroyPool` directly instead of through `pveHandlers`.
54
63
  *
55
64
  * ⚠️ PRIVILEGES, FROM THE SCHEMA. Read and diff need `Sys.Audit` on `/` — both GETs check it.
56
65
  * Reconcile needs `Sys.Modify` on `/` for the POST. `Datastore.Allocate` on `/storage` is NOT
@@ -60,26 +69,15 @@
60
69
  * family checks it, not only the POST. MEASURED the same day as the rest of this paragraph:
61
70
  * `ceph-osd.ts`'s own privilege comparison names `disks/zfs` as one of the sibling families
62
71
  * whose write verbs DO carry a `Sys.Modify` check, unlike `ceph/osd`'s create and delete, which
63
- * carry no permissions block at all and 403 for any identity but `root@pam`. This line
64
- * previously said delete "needs nothing at all, since it calls nothing", which repeated the same
65
- * mistake the header above corrects: `delete` calls the DELETE endpoint, so it needs whatever
66
- * that endpoint checks.
72
+ * carry no permissions block at all and 403 for any identity but `root@pam`.
67
73
  */
68
74
  import { Resource } from 'alchemy';
69
75
  import * as Provider from 'alchemy/Provider';
70
- import type { NodesNodeDisksZfsPostParams } from './generated/pve.ts';
71
- import { type PveRequirements, type WithTarget } from './resource.ts';
72
- /**
73
- * PVE's layouts, generated (2026-09-23) from `NodesNodeDisksZfsPostParams['raidlevel']`
74
- * (`generated/pve.ts`, manifest `pve-apidoc` pve-manager 9.2.11 sha256 9def8f13…) rather than
75
- * hand-typed, per decision 9. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an
76
- * even one. ⛔ THERE IS NO `stripe` IN THIS ENUM. n1's `speed` pool is one (decision 14 accepts it
77
- * as-is): `ZfsPoolProps` below is why a stripe pool is declarable at all — as an ADOPT-ONLY
78
- * declaration with no `raidlevel`, never as a full one.
79
- */
80
- export type ZfsRaidLevel = NonNullable<NodesNodeDisksZfsPostParams['raidlevel']>;
81
- /** Generated the same way, from `NodesNodeDisksZfsPostParams['compression']`. */
82
- export type ZfsCompression = NonNullable<NodesNodeDisksZfsPostParams['compression']>;
76
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
77
+ import type { ZfsPoolAttributes } from './zfs-pool-wire.ts';
78
+ import type { ZfsCompression, ZfsRaidLevel } from './zfs-pool-form.ts';
79
+ export type { ZfsPoolAttributes } from './zfs-pool-wire.ts';
80
+ export type { ZfsCompression, ZfsRaidLevel } from './zfs-pool-form.ts';
83
81
  /**
84
82
  * ⛔ THERE IS NO `add_storage`, `cleanup-config` OR `cleanup-disks` PROP, AND THAT IS NOT AN
85
83
  * OVERSIGHT. The last two belong to the DELETE this resource refuses to make. `add_storage=1`
@@ -116,7 +114,7 @@ export interface ZfsPoolProps extends WithTarget {
116
114
  * both this and `raidlevel` undeclared declares an ADOPT-ONLY pool — one this resource may
117
115
  * read and report but never build. That is the only shape available for a pool PVE's own POST
118
116
  * schema cannot fully describe, such as n1's `speed`, whose layout is a stripe and whose
119
- * `raidlevel` enum (above) has no stripe to declare. `createForm` (zfs-pool-write.ts) omits
117
+ * `raidlevel` enum (above) has no stripe to declare. `createForm` (zfs-pool-form.ts) omits
120
118
  * both keys when they are absent, and `createPool` refuses before any POST if the pool it
121
119
  * would need to build is not already there — an adopt-only declaration never creates. Declare
122
120
  * both fields together for a pool this resource should build from scratch; declaring only one
@@ -145,29 +143,6 @@ export interface ZfsPoolProps extends WithTarget {
145
143
  */
146
144
  'draid-config'?: string;
147
145
  }
148
- /**
149
- * ⚠️ EVERYTHING HERE IS REPORTED, NOTHING HERE IS COMPARED — see the ⛔ on `matches`. These exist
150
- * so a plan and the state store can say what the pool actually is, on a family where the
151
- * declaration and the live object share no comparable field at all.
152
- */
153
- export interface ZfsPoolAttributes {
154
- /** ★ The value a `Proxmox.Storage` should read to order itself after this pool. */
155
- name: string;
156
- node: string;
157
- /** `ONLINE` | `DEGRADED` | `FAULTED` | … Health, not configuration. */
158
- state: string;
159
- /** ZFS's own error summary, e.g. `No known data errors`. */
160
- errors: string;
161
- /**
162
- * The leaf devices ZFS reports, in vdev order, comma-joined.
163
- *
164
- * ⚠️ THIS IS WHAT ZFS RESOLVED, NOT WHAT WAS DECLARED, and the two normally differ: PVE rewrites
165
- * a `/dev/sdb` into a by-id link before creating, and ZFS reports partition paths (`…-part3`)
166
- * for a pool built on partitions — MEASURED, that is exactly what node-b's `rpool` reports. Never
167
- * compare it with `props.devices`.
168
- */
169
- devices: string;
170
- }
171
146
  export interface ProxmoxZfsPool extends Resource<'Proxmox.ZfsPool', ZfsPoolProps, ZfsPoolAttributes, never, PveRequirements> {
172
147
  }
173
148
  /** ★ `retain` by default — a pool holding datasets cannot be rebuilt. See the ★ in resource.ts. */
@@ -1 +1 @@
1
- {"version":3,"file":"zfs-pool.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkEG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,oBAAoB,CAAC;AACtE,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,UAAU,EAA8B,MAAM,eAAe,CAAC;AAIlG;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG,WAAW,CAAC,2BAA2B,CAAC,WAAW,CAAC,CAAC,CAAC;AAEjF,iFAAiF;AACjF,MAAM,MAAM,cAAc,GAAG,WAAW,CAAC,2BAA2B,CAAC,aAAa,CAAC,CAAC,CAAC;AAErF;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,+FAA+F;IAC/F,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,SAAS,CAAC,EAAE,YAAY,CAAC;IACzB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,cAAc,CAAC;IAC7B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,mFAAmF;IACnF,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,YAAY,EACZ,iBAAiB,EACjB,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,mGAAmG;AACnG,eAAO,MAAM,cAAc,iDAEzB,CAAC;AAqBH,eAAO,MAAM,sBAAsB,oIACoD,CAAC"}
1
+ {"version":3,"file":"zfs-pool.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAI7C,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAGtE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvE,YAAY,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvE;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,+FAA+F;IAC/F,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,SAAS,CAAC,EAAE,YAAY,CAAC;IACzB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,cAAc,CAAC;IAC7B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,YAAY,EACZ,iBAAiB,EACjB,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,mGAAmG;AACnG,eAAO,MAAM,cAAc,iDAEzB,CAAC;AAEH,eAAO,MAAM,sBAAsB,oIAyEhC,CAAC"}
@@ -0,0 +1,145 @@
1
+ # `Grafana.Folder` and `Grafana.Dashboard` — `@homeflare/alchemy/grafana`
2
+
3
+ Built 2026-09-24 on the same `@distilled.cloud/grafana` (aliased onto
4
+ `@homeflare/distilled-grafana@0.2.0`) operations that fixed the SDK gap
5
+ [grafana.md](./grafana.md#the-sdk-gap--history-and-what-shipped-on-top-of-the-fix) recorded:
6
+ `createFolder`/`getFolderByUID`/`updateFolder`/`deleteFolder`/`getFolders` and
7
+ `postDashboard`/`getDashboard`/`deleteDashboard`, plus a typed `PreconditionFailed` (412) for
8
+ dashboard version conflicts. No live call was made writing this PR — see "Measured vs inferred"
9
+ below for exactly which claims that limits.
10
+
11
+ ## Both follow `Grafana.Datasource`'s doctrine
12
+
13
+ Same file shape (`resource.ts`'s shared `GrafanaSpec`/`grafanaOperations`/`grafanaHandlers`), same
14
+ credentials layer (`grafanaProviders(target)`), same **uid required, never generated** reasoning —
15
+ `getFolderByUID`/`updateFolder`/`deleteFolder` and `getDashboard`/`postDashboard`/`deleteDashboard`
16
+ are all UID-keyed, and neither `getFolders` nor a dashboard list endpoint takes a per-uid filter,
17
+ so locating an object this resource didn't create would mean listing everything and matching by
18
+ name — the ambiguity requiring `uid` up front sidesteps.
19
+
20
+ ## `Grafana.Folder`
21
+
22
+ ```ts
23
+ import { GrafanaFolder, grafanaProviders } from '@homeflare/alchemy/grafana';
24
+
25
+ export class Infra extends GrafanaFolder('infra-folder', {
26
+ uid: 'infra',
27
+ title: 'Infrastructure',
28
+ }) {}
29
+ ```
30
+
31
+ - **`description` is write-only** — MEASURED against the SDK's generated types: `Folder` (the GET
32
+ response) has no `description` field at all, though `CreateFolderRequest`/`UpdateFolderRequest`
33
+ both accept one. Sent on every create/update that declares it; never compared, the same
34
+ treatment `Grafana.Datasource` gives `secureJsonDataRefs` for the same reason (nothing to diff
35
+ against).
36
+ - **Nesting (`parentUid`) is create-only** — MEASURED: `UpdateFolderRequest` has no `parentUid`
37
+ field (`folder_uid`, `description`, `overwrite`, `title`, `version` only). A declaration whose
38
+ `parentUid` no longer matches the live folder REFUSES with `GrafanaFolderReparentError` rather
39
+ than silently doing nothing or deleting-and-recreating under the new parent (which — next
40
+ bullet — takes every dashboard and alert rule in the folder with it). Move a folder by hand in
41
+ Grafana, then update the declaration.
42
+ - **Delete defaults to `retain`** — `deleteFolder`'s own operation description says it deletes
43
+ "all dashboards (and their alerts) stored in the folder... cannot be reverted... also deletes
44
+ all the subfolders." That is the multi-object cascade `openbao/mount.ts` guards with
45
+ `defaultRemovalPolicy: 'retain'`, verified there against Alchemy's own `Apply.ts` (not
46
+ re-verified here; same engine, same option — see `folder.ts`'s header). A stack that wants the
47
+ cascade opts in with `.pipe(RemovalPolicy.destroy())`; `destroy` still implements the real
48
+ `DELETE` in full either way (S11). `Grafana.Dashboard` does **not** default to retain — see its
49
+ own section below.
50
+
51
+ ## `Grafana.Dashboard`
52
+
53
+ ```ts
54
+ import { GrafanaDashboard, grafanaProviders } from '@homeflare/alchemy/grafana';
55
+
56
+ export class FleetOverview extends GrafanaDashboard('fleet-overview-dashboard', {
57
+ uid: 'fleet-overview',
58
+ folderUid: 'infra',
59
+ dashboard: {
60
+ title: 'Fleet Overview',
61
+ panels: [/* … exported straight from Grafana's own "Export as JSON" … */],
62
+ },
63
+ }) {}
64
+ ```
65
+
66
+ - **The declared content is the dashboard JSON model** — the same shape Grafana's own "Export as
67
+ JSON" produces. `dashboard-model.ts`'s `normalizeModel` strips `id`/`version`/`iteration`
68
+ (Grafana-managed, injected/incremented on every save) and forces `uid` to this resource's own
69
+ value regardless of where — or whether — the source model carried one, before ANY comparison.
70
+ That is what makes a pasted export (which always embeds the OLD live uid) and a freshly-read
71
+ live model compare equal, and what makes a no-change plan a true noop despite those fields
72
+ moving on every live save.
73
+ - **Array order is significant, and nothing here needs a `sortedSet`** — `panels` (screen layout
74
+ via its own `gridPos`, but the array order also drives Grafana's internal panel indexing and
75
+ `repeat` behavior) and each panel's `targets` (query execution/legend order) are compared
76
+ positionally, never reordered. Nothing in a dashboard model is a genuinely unordered SET the way
77
+ UniFi's `trustedDhcpServerIpAddresses` is (`unifi/network-form.ts`'s `sortedSet`) — this family
78
+ has no field that needs one. `schemaVersion` is deliberately left comparing as-is — NOT MEASURED
79
+ whether Grafana rewrites it on a plain save, so normalizing it away on a guess risked hiding a
80
+ genuine schema mismatch instead of a false diff; see `dashboard-model.ts` for the exact reasoning
81
+ and what evidence would justify adding it to the volatile-field list.
82
+ - **`matches` compares "is the declaration a subset of what is live", not plain equality** —
83
+ `dashboard-model.ts`'s `declaredContentMatches`, added after an adversarial review of this PR
84
+ found plain structural equality (`alchemy/Diff`'s `deepEqual`, used in an earlier version of this
85
+ PR) made an ordinary, never-edited dashboard show `update` FOREVER: Grafana's own schema
86
+ migration on save decorates any panel or target whose `datasource` is absent with an explicit
87
+ `{type, uid}` reference — documented Grafana behavior, not re-measured against a live instance in
88
+ this PR. A declared panel that never sets `datasource` therefore read back with that field
89
+ populated, disagreed with the declaration, got written back undecorated, and got redecorated
90
+ again — non-convergent. `declaredContentMatches` instead asks only whether every field the
91
+ DECLARATION mentions, at any depth, still matches what is live; `live` may carry additional
92
+ fields Grafana injected that the declaration never mentioned. Real content drift (a field the
93
+ declaration DOES mention changing, or vanishing) is still caught exactly as before. The
94
+ trade-off, deliberate: **removing** a field from the declaration can no longer force Grafana to
95
+ drop a value it — or a previous declaration — set; only an explicit replacement value changes
96
+ one. This is the same "undeclared means don't care, never means force-to-default" convention this
97
+ whole kit already uses for a plain optional prop, now applied inside the JSON model too.
98
+ - **412 version conflicts fail loudly, never overwritten blindly.** `update` injects the version
99
+ this resource just read (from the same `fetchLive` call `reconcile` already made) into the model
100
+ it sends, and `overwrite` is never set. If Grafana's own check still fails — someone saved in the
101
+ gap between that read and this write — `PreconditionFailed` is left uncaught: the whole reconcile
102
+ fails and nothing is written. The next `plan`/`deploy` re-reads the new live version fresh; there
103
+ is no separate retry loop in this file, because that next run already is the re-read.
104
+ - **No retain default**, unlike `Grafana.Folder`. Deleting one dashboard is a single-object blast
105
+ radius — the same class `Grafana.Datasource` already accepts without a retain guard, not
106
+ `Grafana.Folder`'s multi-object cascade.
107
+
108
+ ## Provisioned objects — refused for writes, adoption is fine
109
+
110
+ A folder or dashboard that a provisioning FILE manages (rather than this API) carries a live
111
+ signal — `Folder.managedBy` (non-empty) and `DashboardMeta.provisioned`/`provisionedExternalId` —
112
+ that `update`/`destroy` check before ever calling the SDK; `GrafanaProvisionedObjectError` names
113
+ the owning file when Grafana reports one (dashboards only — a folder's `managedBy` value is
114
+ reported as-is, since the SDK does not separately expose a folder's owning file). `create` is
115
+ never at risk: it only runs when `fetchLive` already found nothing.
116
+
117
+ `matches` still compares structurally regardless of provisioned status, so a mismatched
118
+ declaration against a provisioned object shows `update` in a plan — never a silent, permanently
119
+ hidden noop — and only fails, loudly, when reconcile actually tries to write. A declaration that
120
+ already matches what is live never reaches `update`/`destroy` at all (the shared engine's
121
+ observe-before-write path), so **adopting a provisioned object read-only is safe.**
122
+
123
+ ⚠️ **Measured vs inferred (no live call in this PR).** `DashboardMeta.provisioned`/
124
+ `provisionedExternalId` are exactly the fields Grafana's classic dashboard-read response types for
125
+ this purpose — high confidence, standard shape. `Folder.managedBy` is INFERRED from the SDK's
126
+ schema only: no live instance in this house currently has a file-provisioned FOLDER to read against
127
+ (`teslamate-grafana` has none at all — see grafana.md). Treating any non-empty `managedBy` as
128
+ provisioned, rather than matching specific string values that were never observed live, is the
129
+ conservative reading; confirm against a real provisioned folder before relying on the exact value in
130
+ an error message. The panel/target `datasource` auto-decoration `declaredContentMatches` (above)
131
+ exists to tolerate is documented Grafana schema-migration behavior, also not re-measured live in
132
+ this PR. And `updateFolder`'s `version` field — sent optimistically for concurrency — carries the
133
+ SDK's own caveat "only used by the legacy folder implementation"; whether Grafana's newer
134
+ unified-storage folder backend honors it at all is unverified (`folder.ts`'s `update`).
135
+
136
+ ## Tests
137
+
138
+ `folder.test.ts`, `dashboard.test.ts` and `dashboard-model.test.ts` mirror `datasource.test.ts`'s
139
+ fake-Grafana harness (`fake-grafana.ts` — the real distilled protocol, only the wire responses
140
+ faked). Each proves: a GET never carries a body (`fake.bodies[0]` is `undefined` — the exact
141
+ "distilled JSON-encodes unknown keys as a body" trap this family's shared memory names); a
142
+ provisioned object refuses update and destroy with no write sent; a transient failure (403/401)
143
+ propagates rather than folding to absent; an unchanged declaration is a noop despite `id`/
144
+ `version`/`iteration` differing; a 412 propagates uncaught; and the folder `parentUid` reparent
145
+ refusal sends no write.
package/docs/grafana.md CHANGED
@@ -6,15 +6,14 @@ any status check. Built 2026-09-24 against the live target `teslamate-grafana.se
6
6
  measured version **13.1.3** (`teslamate/grafana:4.2.0`), listening on `127.0.0.1:3100`, served
7
7
  publicly at `tesla.homeflare.dev/dash/`.
8
8
 
9
- ## The SDK gap — why only `Datasource` ships
9
+ ## The SDK gap — history, and what shipped on top of the fix
10
10
 
11
11
  ✅ **SDK-level gap fixed 2026-09-24** (`@distilled.cloud/grafana`, aliased onto
12
- `@homeflare/distilled-grafana` — 0.1.0 pre-release, 0.2.0 once its `minor` changeset publishes it;
13
- see [docs/distilled-interim.md](./distilled-interim.md)).
14
- The gap below is otherwise unchanged and left as evidence of what was measured and why; only its
15
- status line moved. **Still true today:** only `Grafana.Datasource` ships as a house _resource_ —
16
- `Grafana.Folder`, `Grafana.Dashboard`, `Grafana.AlertRule` and friends are follow-up work on top of
17
- the now-available SDK operations, not part of this fix.
12
+ `@homeflare/distilled-grafana` — 0.2.0; see [docs/distilled-interim.md](./distilled-interim.md)).
13
+ The gap below is left as evidence of what was measured and why. `Grafana.Folder` and
14
+ `Grafana.Dashboard` now ship on top of the unblocked operations —
15
+ [grafana-folder-dashboard.md](./grafana-folder-dashboard.md) covers both in full. `Grafana.AlertRule`,
16
+ `ContactPoint`, `NotificationPolicy` and `MuteTiming` remain follow-up work, not part of this PR.
18
17
 
19
18
  Measured 2026-09-24 against the published `@distilled.cloud/grafana@1.0.0-rc.12` tarball (its
20
19
  `lib/services/grafana.d.ts`, 4,938 lines): the package had **no create/read/update/delete
@@ -125,8 +124,10 @@ export class TeslaMateDatasource extends GrafanaDatasource('teslamate-datasource
125
124
 
126
125
  ## Not covered
127
126
 
128
- - **Folders, dashboards, alert rules, contact points, notification policies, mute timings.** The
129
- SDK-level blocker is fixed (above); no house `Resource` calls these operations yet — follow-up
130
- work, not blocked on anything.
127
+ - **`Grafana.Folder` and `Grafana.Dashboard` ship** — see
128
+ [grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
129
+ - **Alert rules, contact points, notification policies, mute timings.** The SDK-level blocker is
130
+ fixed (above); no house `Resource` calls these operations yet — follow-up work, not blocked on
131
+ anything.
131
132
  - **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
132
133
  still carry (see upstream-conformance.md); a maintainer decision on the ownership-check shape.
@@ -259,10 +259,15 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
259
259
  `@homeflare/distilled-grafana` (0.1.0 pre-release, 0.2.0 once this PR's `minor` changeset
260
260
  releases it), aliased onto `@distilled.cloud/grafana` the same S22 route
261
261
  `@homeflare/distilled-netbox` established. `Grafana.Datasource`'s props/attributes/generated code
262
- are unchanged (diffed operation-by-operation against the prior commit). **Still open, and not
263
- this PR's scope:** no `Grafana.Folder`/`Dashboard`/`AlertRule`/`ContactPoint`/
264
- `NotificationPolicy`/`MuteTiming` resource calls the newly-available operations yet — follow-up
265
- PRs, no longer blocked on the SDK.
262
+ are unchanged (diffed operation-by-operation against the prior commit).
263
+
264
+ ✅ **`Grafana.Folder`/`Grafana.Dashboard` shipped** (this PR, on top of the same
265
+ `@distilled.cloud/grafana` operations S22 unblocked) — the uid-required doctrine, the
266
+ provisioned-object refusal, the parentUid-is-create-only refusal and the volatile-field
267
+ normalization that makes an unchanged dashboard a true noop are all in
268
+ [grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here. **Still open,
269
+ and not this PR's scope:** `Grafana.AlertRule`/`ContactPoint`/`NotificationPolicy`/`MuteTiming`
270
+ — follow-up PRs, no longer blocked on the SDK.
266
271
  - ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
267
272
  (H1), and credentials come from an explicit env var NAME at call time rather than an
268
273
  `alchemy/Auth` provider (S24) — here the house's own `grafanaCredentials(target)`, not the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.32.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",