@homeflare/alchemy 0.32.0 → 0.34.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 (83) hide show
  1. package/dist/grafana/alerting-provenance.d.ts +80 -0
  2. package/dist/grafana/alerting-provenance.d.ts.map +1 -0
  3. package/dist/grafana/contact-point-fixtures.d.ts +28 -0
  4. package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
  5. package/dist/grafana/contact-point.d.ts +99 -0
  6. package/dist/grafana/contact-point.d.ts.map +1 -0
  7. package/dist/grafana/dashboard-model.d.ts +47 -0
  8. package/dist/grafana/dashboard-model.d.ts.map +1 -0
  9. package/dist/grafana/dashboard.d.ts +98 -0
  10. package/dist/grafana/dashboard.d.ts.map +1 -0
  11. package/dist/grafana/datasource.d.ts +2 -9
  12. package/dist/grafana/datasource.d.ts.map +1 -1
  13. package/dist/grafana/folder.d.ts +100 -0
  14. package/dist/grafana/folder.d.ts.map +1 -0
  15. package/dist/grafana/index.d.ts +16 -9
  16. package/dist/grafana/index.d.ts.map +1 -1
  17. package/dist/grafana/index.js +325 -16
  18. package/dist/grafana/index.js.map +15 -5
  19. package/dist/grafana/message-template.d.ts +70 -0
  20. package/dist/grafana/message-template.d.ts.map +1 -0
  21. package/dist/grafana/mute-timing.d.ts +112 -0
  22. package/dist/grafana/mute-timing.d.ts.map +1 -0
  23. package/dist/grafana/providers.d.ts +1 -1
  24. package/dist/grafana/providers.d.ts.map +1 -1
  25. package/dist/grafana/provisioned.d.ts +11 -0
  26. package/dist/grafana/provisioned.d.ts.map +1 -0
  27. package/dist/grafana/secret-refs.d.ts +19 -0
  28. package/dist/grafana/secret-refs.d.ts.map +1 -0
  29. package/dist/grafana/subset-match.d.ts +2 -0
  30. package/dist/grafana/subset-match.d.ts.map +1 -0
  31. package/dist/linux/index.d.ts +1 -1
  32. package/dist/linux/index.d.ts.map +1 -1
  33. package/dist/linux/index.js +3 -1
  34. package/dist/linux/index.js.map +1 -1
  35. package/dist/proxmox/index.js +76 -45
  36. package/dist/proxmox/index.js.map +6 -6
  37. package/dist/proxmox/zfs-pool-form.d.ts +124 -0
  38. package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
  39. package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
  40. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
  41. package/dist/proxmox/zfs-pool.d.ts +27 -52
  42. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  43. package/docs/grafana-alerting.md +145 -0
  44. package/docs/grafana-folder-dashboard.md +145 -0
  45. package/docs/grafana.md +12 -10
  46. package/docs/upstream-conformance.md +31 -4
  47. package/package.json +1 -1
  48. package/src/grafana/alerting-provenance.ts +109 -0
  49. package/src/grafana/contact-point-fixtures.ts +25 -0
  50. package/src/grafana/contact-point-provenance.test.ts +113 -0
  51. package/src/grafana/contact-point-secrets.test.ts +117 -0
  52. package/src/grafana/contact-point.test.ts +123 -0
  53. package/src/grafana/contact-point.ts +179 -0
  54. package/src/grafana/dashboard-model.test.ts +111 -0
  55. package/src/grafana/dashboard-model.ts +62 -0
  56. package/src/grafana/dashboard.test.ts +232 -0
  57. package/src/grafana/dashboard.ts +161 -0
  58. package/src/grafana/datasource.ts +5 -27
  59. package/src/grafana/folder.test.ts +212 -0
  60. package/src/grafana/folder.ts +161 -0
  61. package/src/grafana/index.ts +42 -9
  62. package/src/grafana/message-template.test.ts +212 -0
  63. package/src/grafana/message-template.ts +114 -0
  64. package/src/grafana/mute-timing.test.ts +227 -0
  65. package/src/grafana/mute-timing.ts +160 -0
  66. package/src/grafana/providers.ts +13 -1
  67. package/src/grafana/provisioned.ts +49 -0
  68. package/src/grafana/secret-refs.ts +48 -0
  69. package/src/grafana/subset-match.ts +71 -0
  70. package/src/linux/index-export.test.ts +25 -0
  71. package/src/linux/index.ts +15 -1
  72. package/src/proxmox/constraints-host-forms.test.ts +1 -1
  73. package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
  74. package/src/proxmox/zfs-pool-form.ts +236 -0
  75. package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
  76. package/src/proxmox/zfs-pool-wire.ts +132 -0
  77. package/src/proxmox/zfs-pool.ts +106 -76
  78. package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
  79. package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
  80. package/dist/proxmox/zfs-pool-write.d.ts +0 -128
  81. package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
  82. package/src/proxmox/zfs-pool-spec.ts +0 -76
  83. package/src/proxmox/zfs-pool-write.ts +0 -196
@@ -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,31 +69,23 @@
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';
75
+ import { isResolved } from 'alchemy/Diff';
69
76
  import * as Provider from 'alchemy/Provider';
70
77
  import * as Effect from 'effect/Effect';
71
- import type { NodesNodeDisksZfsPostParams } from './generated/pve.ts';
72
- import { type PveRequirements, type WithTarget, pveHandlers, pveOperations } from './resource.ts';
73
- import { spec } from './zfs-pool-spec.ts';
74
- import { createPool, destroyPool } from './zfs-pool-write.ts';
78
+ import { asForm } from './distilled-guard.ts';
79
+ import { guardForm } from './constraint-guard.ts';
80
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
81
+ import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
82
+ import { dropUnreadable, readPool, readPoolOrFail } from './zfs-pool-wire.ts';
83
+ import type { ZfsPoolAttributes } from './zfs-pool-wire.ts';
84
+ import { createForm, createPool, destroyPool, zfsPoolCreateEndpoint } from './zfs-pool-form.ts';
85
+ import type { ZfsCompression, ZfsRaidLevel } from './zfs-pool-form.ts';
75
86
 
76
- /**
77
- * PVE's layouts, generated (2026-09-23) from `NodesNodeDisksZfsPostParams['raidlevel']`
78
- * (`generated/pve.ts`, manifest `pve-apidoc` pve-manager 9.2.11 sha256 9def8f13…) rather than
79
- * hand-typed, per decision 9. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an
80
- * even one. ⛔ THERE IS NO `stripe` IN THIS ENUM. n1's `speed` pool is one (decision 14 accepts it
81
- * as-is): `ZfsPoolProps` below is why a stripe pool is declarable at all — as an ADOPT-ONLY
82
- * declaration with no `raidlevel`, never as a full one.
83
- */
84
- export type ZfsRaidLevel = NonNullable<NodesNodeDisksZfsPostParams['raidlevel']>;
85
-
86
- /** Generated the same way, from `NodesNodeDisksZfsPostParams['compression']`. */
87
- export type ZfsCompression = NonNullable<NodesNodeDisksZfsPostParams['compression']>;
87
+ export type { ZfsPoolAttributes } from './zfs-pool-wire.ts';
88
+ export type { ZfsCompression, ZfsRaidLevel } from './zfs-pool-form.ts';
88
89
 
89
90
  /**
90
91
  * ⛔ THERE IS NO `add_storage`, `cleanup-config` OR `cleanup-disks` PROP, AND THAT IS NOT AN
@@ -122,7 +123,7 @@ export interface ZfsPoolProps extends WithTarget {
122
123
  * both this and `raidlevel` undeclared declares an ADOPT-ONLY pool — one this resource may
123
124
  * read and report but never build. That is the only shape available for a pool PVE's own POST
124
125
  * schema cannot fully describe, such as n1's `speed`, whose layout is a stripe and whose
125
- * `raidlevel` enum (above) has no stripe to declare. `createForm` (zfs-pool-write.ts) omits
126
+ * `raidlevel` enum (above) has no stripe to declare. `createForm` (zfs-pool-form.ts) omits
126
127
  * both keys when they are absent, and `createPool` refuses before any POST if the pool it
127
128
  * would need to build is not already there — an adopt-only declaration never creates. Declare
128
129
  * both fields together for a pool this resource should build from scratch; declaring only one
@@ -152,30 +153,6 @@ export interface ZfsPoolProps extends WithTarget {
152
153
  'draid-config'?: string;
153
154
  }
154
155
 
155
- /**
156
- * ⚠️ EVERYTHING HERE IS REPORTED, NOTHING HERE IS COMPARED — see the ⛔ on `matches`. These exist
157
- * so a plan and the state store can say what the pool actually is, on a family where the
158
- * declaration and the live object share no comparable field at all.
159
- */
160
- export interface ZfsPoolAttributes {
161
- /** ★ The value a `Proxmox.Storage` should read to order itself after this pool. */
162
- name: string;
163
- node: string;
164
- /** `ONLINE` | `DEGRADED` | `FAULTED` | … Health, not configuration. */
165
- state: string;
166
- /** ZFS's own error summary, e.g. `No known data errors`. */
167
- errors: string;
168
- /**
169
- * The leaf devices ZFS reports, in vdev order, comma-joined.
170
- *
171
- * ⚠️ THIS IS WHAT ZFS RESOLVED, NOT WHAT WAS DECLARED, and the two normally differ: PVE rewrites
172
- * a `/dev/sdb` into a by-id link before creating, and ZFS reports partition paths (`…-part3`)
173
- * for a pool built on partitions — MEASURED, that is exactly what node-b's `rpool` reports. Never
174
- * compare it with `props.devices`.
175
- */
176
- devices: string;
177
- }
178
-
179
156
  export interface ProxmoxZfsPool extends Resource<
180
157
  'Proxmox.ZfsPool',
181
158
  ZfsPoolProps,
@@ -189,24 +166,77 @@ export const ProxmoxZfsPool = Resource<ProxmoxZfsPool>('Proxmox.ZfsPool', {
189
166
  defaultRemovalPolicy: 'retain',
190
167
  });
191
168
 
192
- const ops = pveOperations(spec);
193
-
194
- /**
195
- * ★ THREE HANDLERS COME FROM THE FACTORY AND TWO DO NOT, SO THE TWO ARE THE ONLY ONES WRITTEN OUT.
196
- * `list`, `read` and `diff` are the factory's, unchanged — the spread is what proves it, rather
197
- * than three re-typed delegations nobody rereads. `reconcile` and `delete` are the two the header
198
- * explains, and they are the only places this family departs from every other resource here.
199
- * This is the acl.ts precedent, for a different reason: there the factory's delete hits an
200
- * endpoint PVE does not implement, here it hits one that works and destroys the data.
201
- */
202
- const handlers = {
203
- ...pveHandlers(spec),
204
- delete: Effect.fn(function* ({ olds }: { olds: ZfsPoolProps }) {
205
- yield* destroyPool(olds, spec.path(olds));
206
- }),
207
- reconcile: ({ news }: { news: ZfsPoolProps }) =>
208
- createPool(news, spec.collection(news), spec.path(news), ops.read),
209
- };
210
-
211
169
  export const ProxmoxZfsPoolProvider = () =>
212
- Provider.effect(ProxmoxZfsPool, Effect.succeed(ProxmoxZfsPool.Provider.of(handlers)));
170
+ Provider.effect(
171
+ ProxmoxZfsPool,
172
+ Effect.succeed(
173
+ ProxmoxZfsPool.Provider.of({
174
+ /** ⛔ EMPTY — `GET /nodes/{node}/disks/zfs` returns telemetry for every pool on the node,
175
+ * `rpool` included. Adoption stays explicit. */
176
+ list: () => Effect.succeed([]),
177
+ // ⚠️ FOLDS ONLY WHEN `output` IS `undefined` — storage.ts's own ⚠️, applied here from the
178
+ // start: `output === undefined` (Plan.ts's adoption probe, Apply.ts's delete recovery)
179
+ // keeps `readPool`'s fold; `output !== undefined` (Drift.ts's already-confirmed row)
180
+ // uses `readPoolOrFail` instead, so `alchemy drift` never reports a transient failure as
181
+ // a silent `{action: 'missing'}`.
182
+ read: Effect.fn(function* ({ olds, output }) {
183
+ return dropUnreadable(
184
+ yield* output === undefined ? readPool(olds) : readPoolOrFail(olds),
185
+ );
186
+ }),
187
+ diff: Effect.fn(function* ({ news, output }) {
188
+ if (!isResolved(news)) return undefined;
189
+ // ⚠️ `guardForm`, NOT `guardWrite` — the only migrated family whose create-guard is
190
+ // CONDITIONAL. `guardWrite`'s own signature requires a real `EndpointKey`, but decision
191
+ // 9's adopt-only mode needs `undefined` some of the time (`zfsPoolCreateEndpoint`'s own
192
+ // header) — `guardForm` (constraint-guard.ts) is the one `distilled-guard.ts`'s
193
+ // `guardWrite` itself calls, and it already accepts `EndpointKey | undefined`.
194
+ yield* guardForm(
195
+ zfsPoolCreateEndpoint(news),
196
+ asForm(createForm(news)),
197
+ output === undefined,
198
+ );
199
+ if (output === undefined) return undefined;
200
+ // ⚠️ `readPoolOrFail`, NOT `readPool` — a genuine TRANSIENT failure here propagates and
201
+ // fails the whole plan loudly instead of folding to "absent" and forcing a false
202
+ // update, the cries-wolf class of bug.
203
+ const live = yield* readPoolOrFail(news);
204
+ if (live === UNREADABLE) {
205
+ yield* unreadableWarning('Proxmox.ZfsPool', `${news.node}/${news.name}`);
206
+ return { action: 'noop' } as const;
207
+ }
208
+ // ⛔ FOUND WRITING THIS FILE, 2026-09-24 — an earlier edit that removed the dead
209
+ // `matches()` call (zfs-pool-wire.ts's own ⛔) took this whole branch out with it,
210
+ // leaving a genuinely-absent pool planning `noop` unconditionally instead of `update`
211
+ // — a silent no-op on a state row Alchemy still holds, caught by
212
+ // zfs-pool-adopt.test.ts's own "an adopted pool that vanishes" case rejecting the plan
213
+ // loudly rather than resolving. Restored before this PR opened.
214
+ if (live === undefined) {
215
+ yield* guardForm(zfsPoolCreateEndpoint(news), asForm(createForm(news)), true);
216
+ return { action: 'update' } as const;
217
+ }
218
+ // A live pool means `noop`, unconditionally — `matches` (zfs-pool-wire.ts's own ⛔)
219
+ // always answers `true`, since every create parameter is write-only: there is nothing a
220
+ // declaration and a live pool can be compared on, so nothing here can ever be a replace.
221
+ return { action: 'noop' } as const;
222
+ }),
223
+ /**
224
+ * ⛔ THE ONLY WRITE THIS FAMILY EVER MAKES IS `createPool`'s create — see zfs-pool-form.ts.
225
+ * An existing pool is returned exactly as read, unwritten.
226
+ * ⚠️ `readPool` PIPED THROUGH `dropUnreadable` — `createPool`'s settle-poll treats a
227
+ * refused mid-poll credential exactly like "not found yet" (its own header), so its own
228
+ * `read` parameter is typed `Attributes | undefined`, never `Unreadable`; folding here is
229
+ * what keeps that true rather than leaking the sentinel into `reconcile`'s own return
230
+ * value, which the engine expects to be real attributes or nothing.
231
+ */
232
+ reconcile: Effect.fn(function* ({ news }) {
233
+ return yield* createPool(news, (props) =>
234
+ readPool(props).pipe(Effect.map(dropUnreadable)),
235
+ );
236
+ }),
237
+ delete: Effect.fn(function* ({ olds }) {
238
+ yield* destroyPool(olds);
239
+ }),
240
+ }),
241
+ ),
242
+ );
@@ -1,27 +0,0 @@
1
- /**
2
- * The `PveSpec` for `Proxmox.ZfsPool`: where a pool lives, what its GET reports, and the vendor
3
- * endpoint its create form is checked against.
4
- *
5
- * ★ SPLIT OUT OF zfs-pool.ts TO KEEP THAT FILE UNDER THE 250-LINE CAP, and the seam is the same
6
- * one metric-server-form.ts and zfs-pool-write.ts already cut on: zfs-pool.ts answers "what is
7
- * a zpool and when has it changed", this file answers "what PVE's own GET/{node}/disks/zfs
8
- * collection and item say, read into that shape". `leafDevices` moved with `spec` because it
9
- * exists for exactly one field of `spec.attributes` and nothing else in this package calls it.
10
- *
11
- * ⚠️ THE `import type` BACK TO zfs-pool.ts IS A CYCLE ON PAPER ONLY — type-only, erased before
12
- * anything runs, so the resource's public shape stays in the file that declares the resource.
13
- * Same reasoning as zfs-pool-write.ts.
14
- */
15
- import type { PveSpec } from './resource.ts';
16
- import type { ZfsPoolAttributes, ZfsPoolProps } from './zfs-pool.ts';
17
- /**
18
- * The device paths at the bottom of PVE's vdev tree, in the order ZFS reports them.
19
- *
20
- * ⚠️ "NO CHILDREN" IS THE LEAF TEST, AND IT IS NOT A GUESS — MEASURED in the node's `preparetree`,
21
- * which sets `leaf` to 0 when there are children and 1 otherwise. Reading the flag would work
22
- * equally well; recursing on `children` needs no second field to be present and is what makes
23
- * nested sections (mirrors inside a raid10, `spares`, `cache`) flatten correctly.
24
- */
25
- export declare const leafDevices: (children: unknown) => string[];
26
- export declare const spec: PveSpec<ZfsPoolProps, ZfsPoolAttributes>;
27
- //# sourceMappingURL=zfs-pool-spec.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"zfs-pool-spec.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAG7C,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAErE;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,aAAc,OAAO,KAAG,MAAM,EAO9C,CAAC;AAET,eAAO,MAAM,IAAI,EAAE,OAAO,CAAC,YAAY,EAAE,iBAAiB,CAuCzD,CAAC"}
@@ -1,128 +0,0 @@
1
- /**
2
- * The create lane for `Proxmox.ZfsPool`: the form PVE wants, and the wait for the worker it forks.
3
- *
4
- * ★ SPLIT OUT OF zfs-pool.ts TO KEEP BOTH FILES UNDER THE 250-LINE CAP, and the seam is a real one
5
- * rather than a convenient line number. This file answers "how does a declaration become a pool
6
- * on disks, and how do we know it landed". zfs-pool.ts answers "what is a zpool, what can be
7
- * said about a live one, and when has it changed" — and on this family the answer to the last of
8
- * those is "never", which is why the two halves have so little to say to each other.
9
- *
10
- * ⚠️ THE `import type` BACK TO zfs-pool.ts IS A CYCLE ON PAPER ONLY — it is type-only, so it is
11
- * erased before anything runs, and the resource's public shape stays in the file that declares
12
- * the resource rather than being moved somewhere odd to dodge the arrow. Same as
13
- * metric-server-form.ts.
14
- *
15
- * ⚠️ `createPool` TAKES THE READ AS A PARAMETER, GENERIC IN ITS ERROR AND REQUIREMENT CHANNELS,
16
- * rather than importing `pveOperations` and rebuilding the spec here. Two `pveOperations` over
17
- * one spec would be two closures nobody can prove are the same; passing the one the resource
18
- * already built means this file cannot accidentally read the cluster differently from `diff`.
19
- */
20
- import * as Effect from 'effect/Effect';
21
- import type { EndpointPair } from './resource-spec.ts';
22
- import type { ZfsPoolAttributes, ZfsPoolProps } from './zfs-pool.ts';
23
- /**
24
- * The POST body. Every field in it is write-only — nothing here is ever read back.
25
- *
26
- * ⚠️ `node` IS NOT IN THE BODY: it is already the `nodes/{node}` segment of the URL being POSTed
27
- * to, and a second copy can only disagree with it. Same reasoning as `id` in metric-server.ts.
28
- * ⚠️ `devices` IS JOINED IN DECLARED ORDER AND NOT PUT THROUGH `csv()` FROM values.ts. `csv` sorts,
29
- * and PVE walks this list two at a time to pair `raid10` mirrors — MEASURED in the node's own
30
- * `PVE::API2::Disks::ZFS` — so sorting it builds a different pool from the one declared. The
31
- * list is never compared against anything, so it needs no normalisation, only faithful order.
32
- * ⚠️ AN OMITTED OPTIONAL IS NOT SENT. PVE's own defaults (ashift 12, compression on) are then
33
- * applied by the node. Sending a guessed default would be indistinguishable here — the values
34
- * are unreadable afterwards either way — but it would put a number in the request that no
35
- * declaration asked for, and this is the one call that writes to physical disks.
36
- */
37
- /**
38
- * The vendor rules a FULL declaration's create form is checked against at plan time.
39
- *
40
- * ⛔ HERE RATHER THAN INLINE IN zfs-pool.ts BECAUSE THAT FILE IS ALREADY OVER THE 250-LINE CAP,
41
- * and a `pve:POST …` literal has to live in a non-test source file for the generator to table
42
- * it at all. zfs-pool-spec.ts imports it on the line it already imports `createForm` from.
43
- * ⚠️ NO UPDATE KEY: PVE registers no PUT under `/nodes/{node}/disks/zfs`, which is exactly why
44
- * the spec declares no `updateForm` and a changed prop is a REPLACE.
45
- * ⚠️ `spec.endpoint` DOES NOT RETURN THIS CONSTANT DIRECTLY — see the ★ on `zfsPoolEndpoint`
46
- * below, and the ⛔ on `devices`/`raidlevel` in zfs-pool.ts (decision 9, 2026-09-23).
47
- */
48
- export declare const ZFS_POOL_ENDPOINT: EndpointPair;
49
- /**
50
- * ★ `PveSpec['endpoint']` AS A FUNCTION OF PROPS, THE WAY notification-target.ts AND
51
- * ceph-daemon.ts ALREADY DO IT FOR A DIFFERENT REASON (resource-spec.ts's ⚠️ on that field).
52
- * Here the reason is decision 9's adopt-only mode: `guardCreate` runs on the FIRST plan of
53
- * ANY declaration (resource.ts `ops.diff`, `output === undefined` branch) and checks the
54
- * create form's REQUIRED fields whenever `endpoint.create` resolves to a key — and PVE marks
55
- * both `devices` and `raidlevel` required (measured, codegen/constraints's `pve-nodes-disks`
56
- * row). An adopt-only declaration deliberately omits both, so returning the real key there
57
- * would refuse a MATCHING adoption at plan time, before `read` ever ran — exactly the gap
58
- * `guardCreate`/`guardUpdate` being exported (resource.ts's ★) says this family must not fall
59
- * through. Returning `{}` (no `create` key) makes `guardCreate` a no-op (`constraint-guard.ts`:
60
- * `create === undefined ? Effect.void : …`) for an adopt-only declaration, while a FULL one
61
- * (`devices` and `raidlevel` both present) is checked exactly as before.
62
- * ⚠️ THE RUNTIME BACKSTOP IS `createPool` BELOW, NOT THIS FUNCTION. Skipping the plan-time guard
63
- * for an adopt-only declaration does not skip the check that the pool is actually there —
64
- * `createPool` refuses before any POST when the read comes back absent and either field is
65
- * undefined, which is what a genuinely absent adopt-only declaration hits at apply time.
66
- */
67
- export declare const zfsPoolEndpoint: (props: ZfsPoolProps) => EndpointPair;
68
- /**
69
- * ⚠️ EVERY KEY IS OMITTED WHEN ABSENT, `devices` AND `raidlevel` INCLUDED — not just the
70
- * optional ones. An adopt-only declaration (both undefined, decision 9) must produce a form
71
- * `createPool`'s own guard can inspect for their absence, and one this function itself does
72
- * not throw building: `props.devices.join(',')` on an undefined `devices` is exactly the
73
- * `TypeError` the plan-time guard hit before this change, because `guardCreate` evaluates
74
- * `spec.createForm(props)` eagerly (resource-guard.ts) — before it even looks at `presence`.
75
- */
76
- export declare const createForm: (props: ZfsPoolProps) => Record<string, string>;
77
- /**
78
- * Create the pool if it is absent, and otherwise leave it completely alone.
79
- *
80
- * ⛔ THE POST ONLY FORKS A WORKER. MEASURED in `/usr/share/perl5/PVE/API2/Disks/ZFS.pm` on node-b: the
81
- * create handler ends in `$rpcenv->fork_worker('zfscreate', ...)` and its HTTP answer is a UPID
82
- * returned the instant the worker is forked — before `zpool create` has run, let alone finished.
83
- * A read-back taken immediately, as `pveOperations.reconcile` takes it, finds nothing and would
84
- * report a successful create as the failure "the write returned no error but the object is still
85
- * absent". THAT is why this function exists instead of the factory's reconcile.
86
- *
87
- * ⚠️ THERE IS NO UPDATE BRANCH BECAUSE PVE HAS NO PUT HERE. An existing pool is returned exactly as
88
- * read, unwritten: the only write this resource ever makes is the create of a pool that is not
89
- * there. Do not add a "repair" write; there is no field it could set.
90
- *
91
- * ⛔ AND IF THE READ IS WRONG, THE POST IS STILL SAFE — measured, not hoped for. `read` folds a 403
92
- * or an unreachable node into "absent", so a too-narrow lease would send this down the create
93
- * path over a live pool. PVE's handler calls `get_pool_data()` before it forks anything and dies
94
- * with "pool '<name>' already exists on node '<node>'", and `assert_disk_unused` refuses every
95
- * device that pool is holding. A create aimed at a pool that is really there fails loudly rather
96
- * than wiping it.
97
- *
98
- * ⚠️ SHORT POLLS WITH A HARD CAP, NOT ONE LONG WAIT. `read` cannot tell "not yet" from "forbidden"
99
- * — both are `undefined` — so the loop gives up with the UPID rather than retrying forever, and
100
- * the UPID is the only thing that leads to the worker's real error.
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). `zfsPoolEndpoint` above already keeps the vendor constraint guard
106
- * from refusing such a declaration at PLAN time; this is the matching refusal at APPLY time, for
107
- * the one case that guard cannot see — the pool genuinely is not there. MEASURED without it: a
108
- * tolerant `createForm` sends `POST … {name: "rpool"}` with no `devices`/`raidlevel`, which PVE's
109
- * own schema would 400 on eventually, but only after the request left this process; refusing
110
- * here is the same `Effect.die` idiom as every other refusal in this package, and it costs
111
- * nothing this pool did not already lack.
112
- */
113
- export declare const createPool: <E, R>(props: ZfsPoolProps, collection: string, path: string, read: (props: ZfsPoolProps) => Effect.Effect<ZfsPoolAttributes | undefined, E, R>) => Effect.Effect<ZfsPoolAttributes, E | Error | import("./credential-errors.ts").PveCredentialDenied, R | import("effect/unstable/http/HttpClient").HttpClient>;
114
- /**
115
- * ⛔ `DELETE /nodes/{node}/disks/zfs/{name}` RUNS `zpool destroy` AND THE DATASETS GO WITH IT. Per
116
- * S14 (alchemy-provider-standard), it is implemented rather than stubbed — a `delete` that
117
- * silently does nothing lies to whoever reads the plan — and what keeps an ordinary removed
118
- * declaration from reaching it is the resource's `defaultRemovalPolicy: 'retain'` (zfs-pool.ts):
119
- * an orphaned pool is forgotten, not destroyed, unless a caller opts in with
120
- * `.pipe(RemovalPolicy.destroy())`. See the corrected header in zfs-pool.ts.
121
- *
122
- * ⚠️ `cleanup-config` IS NOT SENT. It would additionally remove the PVE storage entry pointing
123
- * at this pool, which is a different object with its own resource (`Proxmox.Storage`); one
124
- * resource reaching over to delete another's is how a stack ends up with state describing
125
- * something that is gone.
126
- */
127
- export declare const destroyPool: (olds: ZfsPoolProps, path: string) => Effect.Effect<unknown, Error | import("./credential-errors.ts").PveCredentialDenied, import("effect/unstable/http/HttpClient").HttpClient>;
128
- //# sourceMappingURL=zfs-pool-write.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"zfs-pool-write.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-write.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,EAAE,YAA6D,CAAC;AAE9F;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,eAAe,UAAW,YAAY,KAAG,YACiC,CAAC;AAExF;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,UAAW,YAAY,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAOpE,CAAC;AAKH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,CAAC,SACtB,YAAY,cACP,MAAM,QACZ,MAAM,QACN,CAAC,KAAK,EAAE,YAAY,KAAK,MAAM,CAAC,MAAM,CAAC,iBAAiB,GAAG,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC,iKA8C/E,CAAC;AAEL;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,SAAU,YAAY,QAAQ,MAAM,+IACb,CAAC"}
@@ -1,76 +0,0 @@
1
- /**
2
- * The `PveSpec` for `Proxmox.ZfsPool`: where a pool lives, what its GET reports, and the vendor
3
- * endpoint its create form is checked against.
4
- *
5
- * ★ SPLIT OUT OF zfs-pool.ts TO KEEP THAT FILE UNDER THE 250-LINE CAP, and the seam is the same
6
- * one metric-server-form.ts and zfs-pool-write.ts already cut on: zfs-pool.ts answers "what is
7
- * a zpool and when has it changed", this file answers "what PVE's own GET/{node}/disks/zfs
8
- * collection and item say, read into that shape". `leafDevices` moved with `spec` because it
9
- * exists for exactly one field of `spec.attributes` and nothing else in this package calls it.
10
- *
11
- * ⚠️ THE `import type` BACK TO zfs-pool.ts IS A CYCLE ON PAPER ONLY — type-only, erased before
12
- * anything runs, so the resource's public shape stays in the file that declares the resource.
13
- * Same reasoning as zfs-pool-write.ts.
14
- */
15
- import type { PveSpec } from './resource.ts';
16
- import { text } from './values.ts';
17
- import { createForm, zfsPoolEndpoint } from './zfs-pool-write.ts';
18
- import type { ZfsPoolAttributes, ZfsPoolProps } from './zfs-pool.ts';
19
-
20
- /**
21
- * The device paths at the bottom of PVE's vdev tree, in the order ZFS reports them.
22
- *
23
- * ⚠️ "NO CHILDREN" IS THE LEAF TEST, AND IT IS NOT A GUESS — MEASURED in the node's `preparetree`,
24
- * which sets `leaf` to 0 when there are children and 1 otherwise. Reading the flag would work
25
- * equally well; recursing on `children` needs no second field to be present and is what makes
26
- * nested sections (mirrors inside a raid10, `spares`, `cache`) flatten correctly.
27
- */
28
- export const leafDevices = (children: unknown): string[] =>
29
- Array.isArray(children)
30
- ? children.flatMap((entry: unknown) => {
31
- const vdev = entry as { children?: unknown; name?: unknown };
32
- const nested = leafDevices(vdev.children);
33
- return nested.length > 0 ? nested : [text(vdev.name)].filter((name) => name !== '');
34
- })
35
- : [];
36
-
37
- export const spec: PveSpec<ZfsPoolProps, ZfsPoolAttributes> = {
38
- /**
39
- * ⚠️ `scan`, `status` AND `action` ARE DELIBERATELY NOT ATTRIBUTES. All three are advisory
40
- * strings ZFS rewrites on its own — `scan` on every scrub, the other two as feature flags and
41
- * faults come and go — so keeping them would rewrite this resource's state row for reasons no
42
- * declaration caused. Same reasoning as `digest` in storage.ts.
43
- */
44
- attributes: (live, props) => {
45
- // ⚠️ A detail answer with no `name` is not a pool. `name` is non-optional in PVE's schema, so
46
- // this is a shape check rather than a default — and "absent" is the honest reading of a
47
- // reply that does not describe the object that was asked for.
48
- if (text(live['name']) === '') return undefined;
49
- return {
50
- devices: leafDevices(live['children']).join(','),
51
- errors: text(live['errors'], 'unknown'),
52
- name: props.name,
53
- node: props.node,
54
- state: text(live['state'], 'unknown'),
55
- };
56
- },
57
- collection: (props) => `nodes/${props.node}/disks/zfs`,
58
- createForm,
59
- /** ★ A function of props, not the constant — see the ★ on `zfsPoolEndpoint` (zfs-pool-write.ts). */
60
- endpoint: zfsPoolEndpoint,
61
- /**
62
- * ⛔ IT ALWAYS ANSWERS TRUE, AND THAT IS THE POINT OF THIS FILE. A pool that is there under the
63
- * declared name on the declared node IS the declaration, because there is nothing else the two
64
- * can be compared on: every create parameter is write-only (see the ⛔ in zfs-pool.ts's header)
65
- * and everything the read does return is telemetry that moves by itself. So declaring what is
66
- * live plans as `noop` — the only honest answer available, and the one the live cluster needs:
67
- * `rpool` exists on node-b, node-c and node-d with nothing about its construction readable.
68
- * ⛔ ANYTHING ADDED HERE IS A REPLACE, NOT AN UPDATE. `updateForm` is omitted because PVE has
69
- * no PUT, so `resource.ts` turns a false into `{action:'replace'}` — delete then create — on
70
- * a family whose delete is `zpool destroy`. A comparison added here would be one plan away
71
- * from destroying a pool over a field it cannot even read back. If a future PVE grows a PUT,
72
- * add `updateForm` FIRST and only then consider comparing what that PUT accepts.
73
- */
74
- matches: () => true,
75
- path: (props) => `nodes/${props.node}/disks/zfs/${props.name}`,
76
- };