@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
@@ -138,4 +138,30 @@ describe('adopting an adopt-only ZfsPool declaration', () => {
138
138
  const r: ZfsRaidLevel = 'stripe';
139
139
  expect(String(r)).toBe('stripe');
140
140
  });
141
+
142
+ /**
143
+ * ⛔ FOUND BY ADVERSARIAL REVIEW, 2026-09-24: a genuinely FIRST-EVER declaration never reaches
144
+ * `diff` at all (upstream `Plan.ts` routes `oldState === undefined` straight to `create`), so
145
+ * the vendor-constraint check `diff` runs had never once protected a brand-new pool's own
146
+ * create -- only an ADOPT's later plans. `createPool` (zfs-pool-form.ts) now re-checks before
147
+ * its own POST, the same pattern `ceph-pool.ts`'s hand-written `reconcile` already uses.
148
+ */
149
+ test('a malformed ashift on a brand-new (non-adopt) declaration is refused before any write', async () => {
150
+ const fake = cluster(new Set());
151
+ await withoutBao(async () => {
152
+ const engine = engineFor(fake);
153
+ const bad = () =>
154
+ ProxmoxZfsPool('bad', {
155
+ // ⛔ PVE's own bound is 9-16 (generated/constraints/pve-nodes-disks.ts) -- 99 is refused.
156
+ ashift: 99,
157
+ devices: ['/dev/disk/by-id/fake-0'],
158
+ name: 'newpool',
159
+ node: 'node-b',
160
+ raidlevel: 'single',
161
+ target: FAKE_TARGET,
162
+ });
163
+ await expect(engine.deploy(bad())).rejects.toBeDefined();
164
+ });
165
+ expect(fake.writes()).toEqual([]);
166
+ });
141
167
  });
@@ -0,0 +1,236 @@
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 { asForm } from './distilled-guard.ts';
15
+ import { guardForm } from './constraint-guard.ts';
16
+ import { runPve } from './distilled-pve.ts';
17
+ import { text } from './values.ts';
18
+ import type { ZfsPoolAttributes, ZfsPoolProps } from './zfs-pool.ts';
19
+ import type { NodesNodeDisksZfsPostParams } from './generated/pve.ts';
20
+
21
+ export const ZFS_POOL_CREATE = 'pve:POST /nodes/{node}/disks/zfs';
22
+
23
+ /**
24
+ * PVE's layouts, generated (2026-09-23) from `NodesNodeDisksZfsPostParams['raidlevel']`
25
+ * (`generated/pve.ts`, manifest `pve-apidoc` pve-manager 9.2.11 sha256 9def8f13…) rather than
26
+ * hand-typed, per decision 9. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an
27
+ * even one. ⛔ THERE IS NO `stripe` IN THIS ENUM. n1's `speed` pool is one (decision 14 accepts it
28
+ * as-is): `ZfsPoolProps` (zfs-pool.ts) is why a stripe pool is declarable at all — as an
29
+ * ADOPT-ONLY declaration with no `raidlevel`, never as a full one.
30
+ */
31
+ export type ZfsRaidLevel = NonNullable<NodesNodeDisksZfsPostParams['raidlevel']>;
32
+
33
+ /** Generated the same way, from `NodesNodeDisksZfsPostParams['compression']`. */
34
+ export type ZfsCompression = NonNullable<NodesNodeDisksZfsPostParams['compression']>;
35
+
36
+ /**
37
+ * ★ CHECKED AGAINST THE VENDOR TABLE ONLY FOR A FULL DECLARATION — decision 9's adopt-only mode.
38
+ * `guardCreate` (distilled-guard.ts's `guardWrite`) runs on the FIRST plan of ANY declaration and
39
+ * checks the create form's REQUIRED fields whenever this resolves to the real endpoint key — and
40
+ * PVE marks both `devices` and `raidlevel` required (codegen/constraints's `pve-nodes-disks`
41
+ * row). An adopt-only declaration deliberately omits both, so checking it would refuse a MATCHING
42
+ * adoption at plan time, before `read` ever ran. Returning `undefined` (no key) makes the guard a
43
+ * no-op for an adopt-only declaration, while a FULL one (`devices` and `raidlevel` both present)
44
+ * is checked exactly as before — zfs-pool.ts's `diff` passes this as the `guardWrite` endpoint.
45
+ * ⚠️ THE RUNTIME BACKSTOP IS `createPool` BELOW, NOT THIS FUNCTION. Skipping the plan-time guard
46
+ * for an adopt-only declaration does not skip the check that the pool is actually there —
47
+ * `createPool` refuses before any POST when the read comes back absent and either field is
48
+ * undefined, which is what a genuinely absent adopt-only declaration hits at apply time.
49
+ */
50
+ export const zfsPoolCreateEndpoint = (props: ZfsPoolProps) =>
51
+ props.devices === undefined || props.raidlevel === undefined ? undefined : ZFS_POOL_CREATE;
52
+
53
+ /**
54
+ * The wire form, PVE's own field names — what `guardWrite` (zfs-pool.ts) checks.
55
+ *
56
+ * ⚠️ `devices` IS JOINED IN DECLARED ORDER AND NOT PUT THROUGH `csv()` FROM values.ts. `csv` sorts,
57
+ * and PVE walks this list two at a time to pair `raid10` mirrors — MEASURED in the node's own
58
+ * `PVE::API2::Disks::ZFS` — so sorting it builds a different pool from the one declared. The list
59
+ * is never compared against anything, so it needs no normalisation, only faithful order.
60
+ * ⚠️ AN OMITTED OPTIONAL IS NOT SENT. PVE's own defaults (ashift 12, compression on) are then
61
+ * applied by the node. Sending a guessed default would be indistinguishable here — the values
62
+ * are unreadable afterwards either way — but it would put a number in the request that no
63
+ * declaration asked for, and this is the one call that writes to physical disks.
64
+ * ⚠️ `node` IS NOT IN THE FORM: it is the `nodes/{node}` path label, and a second copy could only
65
+ * disagree with it.
66
+ * ⚠️ EVERY KEY IS OMITTED WHEN ABSENT, `devices` AND `raidlevel` INCLUDED — not just the optional
67
+ * ones. An adopt-only declaration (both undefined, decision 9) must produce a form `createPool`'s
68
+ * own guard can inspect for their absence, and one this function itself does not throw building.
69
+ */
70
+ export const createForm = (props: ZfsPoolProps): Record<string, string> => ({
71
+ ...(props.devices === undefined ? {} : { devices: props.devices.join(',') }),
72
+ name: props.name,
73
+ ...(props.raidlevel === undefined ? {} : { raidlevel: props.raidlevel }),
74
+ ...(props.ashift === undefined ? {} : { ashift: String(props.ashift) }),
75
+ ...(props.compression === undefined ? {} : { compression: props.compression }),
76
+ ...(props['draid-config'] === undefined ? {} : { 'draid-config': props['draid-config'] }),
77
+ });
78
+
79
+ /**
80
+ * The actual `createNodeDiskZfs` call body — `createForm` plus the `node` label, `draid-config`
81
+ * renamed to distilled's `draid_config` (`T.Body("draid-config")` on the generated schema; the
82
+ * ONE hyphenated field this family's create form carries — storage-form.ts's `underscored` has
83
+ * the fuller measurement, not repeated here for one field).
84
+ *
85
+ * ⚠️ `as unknown as` — `devices`/`raidlevel` are REQUIRED in distilled's generated
86
+ * `CreateNodeDiskZfsRequest`, so an adopt-only form (both omitted) cannot satisfy it structurally
87
+ * even though it is a valid PVE request for `guardWrite`'s purposes. `createPool` below refuses
88
+ * before ever reaching this cast when either is genuinely missing, so the cast never carries a
89
+ * request distilled's OWN required fields would reject in practice.
90
+ */
91
+ const toDistilledCreate = (props: ZfsPoolProps): nodes.CreateNodeDiskZfsRequest => {
92
+ const form = createForm(props);
93
+ return {
94
+ ...form,
95
+ draid_config: form['draid-config'],
96
+ node: props.node,
97
+ } as unknown as nodes.CreateNodeDiskZfsRequest;
98
+ };
99
+
100
+ /** 30 reads, 2s apart. A create that has not landed in a minute has gone wrong, not gone slow. */
101
+ const SETTLE_ATTEMPTS = 30;
102
+
103
+ /**
104
+ * Create the pool if it is absent, and otherwise leave it completely alone.
105
+ *
106
+ * ⛔ THE POST ONLY FORKS A WORKER. MEASURED in `/usr/share/perl5/PVE/API2/Disks/ZFS.pm` on node-b:
107
+ * the create handler ends in `$rpcenv->fork_worker('zfscreate', ...)` and its HTTP answer is a
108
+ * UPID returned the instant the worker is forked — before `zpool create` has run, let alone
109
+ * finished. A read-back taken immediately would report a successful create as the failure "the
110
+ * write returned no error but the object is still absent". THAT is why this function polls.
111
+ *
112
+ * ⚠️ THERE IS NO UPDATE BRANCH BECAUSE PVE HAS NO PUT HERE. An existing pool is returned exactly as
113
+ * read, unwritten: the only write this resource ever makes is the create of a pool that is not
114
+ * there. Do not add a "repair" write; there is no field it could set.
115
+ *
116
+ * ⛔ AND IF THE READ IS WRONG, THE POST IS STILL SAFE — measured, not hoped for. `readPool` folds a
117
+ * 403 or an unreachable node into "absent", so a too-narrow lease would send this down the create
118
+ * path over a live pool. PVE's handler calls `get_pool_data()` before it forks anything and dies
119
+ * with "pool '<name>' already exists on node '<node>'", and `assert_disk_unused` refuses every
120
+ * device that pool is holding. A create aimed at a pool that is really there fails loudly rather
121
+ * than wiping it.
122
+ *
123
+ * ⚠️ SHORT POLLS WITH A HARD CAP, NOT ONE LONG WAIT, NOT `@distilled.cloud/proxmox`'s `awaitTask`.
124
+ * `readPool` cannot tell "not yet" from "forbidden" — both fold to `undefined` — so the loop
125
+ * gives up with the UPID rather than retrying forever, and the UPID is the only thing that leads
126
+ * to the worker's real error. Distilled's own task-status poller exists (`Task.ts`) and would
127
+ * remove this ambiguity, but swapping to it changes this resource's OBSERVABLE behaviour (what a
128
+ * caller sees on a slow create) beyond a transport swap — left as a follow-up, not folded into a
129
+ * migration PR silently.
130
+ *
131
+ * ⛔ THE SAME AMBIGUITY MAKES A RE-RUN AFTER THE GIVE-UP DIE GENUINELY UNSAFE, NOT MERELY SLOW TO
132
+ * CONFIRM — FOUND BY ADVERSARIAL REVIEW, 2026-09-24, of THIS migration, though the shape is
133
+ * unchanged from before it (`git blame` shows the same settle-poll pre-dates this PR). "If the
134
+ * read is wrong, the POST is still safe" above is true for the FIRST call into this function —
135
+ * PVE's own `get_pool_data()` refuses a create over a pool it can already see. It is NOT true
136
+ * for a SECOND deploy attempt started while the FIRST worker is still mid-`zpool create`: that
137
+ * worker has not yet made the pool visible to a read, so `existing = yield* read(props)` at the
138
+ * top of THIS call can also fold to `undefined` — not because the pool is gone, but because the
139
+ * first attempt has not finished — and this function would POST a SECOND `zpool create` at the
140
+ * SAME devices while the first is still writing them. There is no lock or marker anywhere in
141
+ * this package that would stop it. The give-up message below says so explicitly and names the
142
+ * task log to check FIRST; that message, and an operator reading it, are the only safeguard.
143
+ *
144
+ * ⛔ THE ADOPT-ONLY GUARD, RIGHT AFTER THE READ AND BEFORE ANYTHING ELSE. Decision 9 (2026-09-23):
145
+ * omitting `devices` and `raidlevel` declares a pool this resource may only ADOPT, for a pool
146
+ * PVE's own schema cannot fully describe — n1's `speed` is a stripe, and `raidlevel`'s enum has
147
+ * no stripe (zfs-pool.ts). `zfsPoolCreateEndpoint` above already keeps the vendor constraint
148
+ * guard from refusing such a declaration at PLAN time; this is the matching refusal at APPLY
149
+ * time, for the one case that guard cannot see — the pool genuinely is not there.
150
+ */
151
+ export const createPool = <E, R>(
152
+ props: ZfsPoolProps,
153
+ read: (props: ZfsPoolProps) => Effect.Effect<ZfsPoolAttributes | undefined, E, R>,
154
+ ) =>
155
+ Effect.gen(function* () {
156
+ const existing = yield* read(props);
157
+ if (existing !== undefined) return existing;
158
+
159
+ if (props.devices === undefined || props.raidlevel === undefined) {
160
+ return yield* Effect.die(
161
+ new Error(
162
+ `${props.node}/${props.name}: this is an adopt-only declaration (no \`devices\` or ` +
163
+ '`raidlevel`) and the pool is not there to adopt. An adopt-only Proxmox.ZfsPool never ' +
164
+ 'creates -- declare both fields for a pool this resource should build, or point this ' +
165
+ 'declaration at a pool that already exists on this node.',
166
+ ),
167
+ );
168
+ }
169
+
170
+ /**
171
+ * ⛔ FOUND BY ADVERSARIAL REVIEW, 2026-09-24 — `diff` NEVER RUNS FOR A GENUINELY FIRST-EVER
172
+ * DECLARATION. Upstream `Plan.ts` routes a brand-new resource (`oldState === undefined`)
173
+ * straight to `action: 'create'` without ever calling `provider.diff` — so `zfs-pool.ts`'s
174
+ * own `guardForm` call (which checks THIS SAME form) never runs for that plan, and this was
175
+ * THE ONLY WRITE PATH IN THE FAMILY WITH NO VENDOR CHECK AT ALL, before or after this
176
+ * migration. `ceph-pool.ts`'s own hand-written `reconcile` already re-runs its create guard
177
+ * for exactly this reason (an ADOPTED row's `diff` answer is discarded by Plan.ts too); this
178
+ * is the same fix for this family. A malformed `ashift` or `draid-config` is now refused
179
+ * here, before the POST, rather than reaching the wire and refused only by PVE itself.
180
+ */
181
+ yield* guardForm(zfsPoolCreateEndpoint(props), asForm(createForm(props)), true);
182
+
183
+ const upid = yield* runPve(
184
+ props.target,
185
+ 'provision',
186
+ true,
187
+ nodes.createNodeDiskZfs(toDistilledCreate(props)),
188
+ );
189
+
190
+ const settle = (attempts: number): Effect.Effect<ZfsPoolAttributes | undefined, E, R> =>
191
+ Effect.gen(function* () {
192
+ const live = yield* read(props);
193
+ if (live !== undefined || attempts <= 0) return live;
194
+ yield* Effect.sleep('2 seconds');
195
+ return yield* settle(attempts - 1);
196
+ });
197
+
198
+ const after = yield* settle(SETTLE_ATTEMPTS);
199
+ if (after === undefined) {
200
+ return yield* Effect.die(
201
+ new Error(
202
+ `nodes/${props.node}/disks/zfs/${props.name}: POST returned ${text(upid, '(no UPID)')} ` +
203
+ 'but the pool has not appeared. That POST only forks a `zfscreate` worker, so the task ' +
204
+ `holds the real error -- read it with \`pvesh get /nodes/${props.node}/tasks/<upid>/log\`. ` +
205
+ 'NOTHING WAS RETRIED AND NOTHING WAS CLEANED UP: if the worker reached `zpool create`, ' +
206
+ 'the declared devices have already been written to. DO NOT RE-RUN THIS DEPLOY BEFORE ' +
207
+ 'READING THAT LOG: `read` cannot tell "the worker is still running" from "the pool is ' +
208
+ 'genuinely gone", so a re-run this soon can POST a SECOND `zpool create` at the SAME ' +
209
+ 'devices while the first one is still mid-write -- confirm the task finished (one way ' +
210
+ 'or the other) before trying again.',
211
+ ),
212
+ );
213
+ }
214
+ return after;
215
+ });
216
+
217
+ /**
218
+ * ⛔ `DELETE /nodes/{node}/disks/zfs/{name}` RUNS `zpool destroy` AND THE DATASETS GO WITH IT. Per
219
+ * S14 (alchemy-provider-standard), it is implemented rather than stubbed — a `delete` that
220
+ * silently does nothing lies to whoever reads the plan — and what keeps an ordinary removed
221
+ * declaration from reaching it is the resource's `defaultRemovalPolicy: 'retain'` (zfs-pool.ts):
222
+ * an orphaned pool is forgotten, not destroyed, unless a caller opts in with
223
+ * `.pipe(RemovalPolicy.destroy())`. See the corrected header in zfs-pool.ts.
224
+ *
225
+ * ⚠️ `cleanup-config`/`cleanup-disks` ARE NOT SENT. The first would additionally remove the PVE
226
+ * storage entry pointing at this pool, which is a different object with its own resource
227
+ * (`Proxmox.Storage`); one resource reaching over to delete another's is how a stack ends up with
228
+ * state describing something that is gone. Neither is offered as a prop, for the same reason.
229
+ */
230
+ export const destroyPool = (props: ZfsPoolProps) =>
231
+ runPve(
232
+ props.target,
233
+ 'provision',
234
+ true,
235
+ nodes.deleteNodeDiskZfs({ name: props.name, node: props.node }),
236
+ );
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `Proxmox.ZfsPool`'s own cries-wolf fix, PLUS the request-shape and transient-failure fixes
3
+ * kit 0.31.2/0.31.3 (PR 239/243) found for User/Group/Storage — all apply here too, this time on
4
+ * a `read`-role read (this family never needed `provision` for GETs). Real-shaped fixtures (live
5
+ * TB4 `n2` response bodies, measured 2026-09-24, nothing secret): `GET /nodes/n2/disks/zfs/rpool`
6
+ * -> `{"data":{"name":"rpool","state":"ONLINE","errors":"No known data errors","children":[...]}}`,
7
+ * and a missing pool -> a generic shelled-out `zpool status` failure at HTTP 500, the same class
8
+ * of measured 500 as every other family this package has migrated.
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import * as Layer from 'effect/Layer';
12
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
13
+ import { engineOver } from '../verify/fake-engine.ts';
14
+ import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
15
+ import { ProxmoxZfsPool, ProxmoxZfsPoolProvider } from './zfs-pool.ts';
16
+
17
+ /**
18
+ * One pool, an OpenBao mint the test can deny for `read` mid-run (the cries-wolf case), and a
19
+ * cluster answer the test can break into a transient 500 mid-run (the request-shape regression's
20
+ * own case) — never both at once, so one fixture serves every test below.
21
+ */
22
+ const clusterOverPool = (node: string, name: string, live: Record<string, unknown>) => {
23
+ let denyRead = false;
24
+ let transient = false;
25
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
26
+ const request =
27
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
28
+ const url = new URL(request.url);
29
+ if (url.pathname.includes('/creds/')) {
30
+ if (denyRead && url.pathname.endsWith('/creds/read')) {
31
+ return Response.json({ errors: ['permission denied'] }, { status: 403 });
32
+ }
33
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
34
+ return Response.json({ data, lease_duration: 0 });
35
+ }
36
+ if (request.method === 'GET') {
37
+ // ⛔ THE REQUEST-SHAPE REGRESSION'S OWN SIGNATURE (user.ts/group.ts/storage.ts): a real
38
+ // fetch client refuses a GET carrying a body outright.
39
+ expect(await request.text()).toBe('');
40
+ }
41
+ const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
42
+ if (wire !== `nodes/${node}/disks/zfs/${name}`) return Response.json({ data: null });
43
+ if (transient) {
44
+ // MEASURED 2026-09-24, live TB4: a missing pool answers exactly this shape.
45
+ return Response.json(
46
+ { data: null, message: `command '/sbin/zpool status -P ${name}' failed: exit code 1\n` },
47
+ { status: 500 },
48
+ );
49
+ }
50
+ return Response.json({ data: live });
51
+ };
52
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
53
+ return {
54
+ breakReads: () => {
55
+ transient = true;
56
+ },
57
+ denyRead: () => {
58
+ denyRead = true;
59
+ },
60
+ layer: FetchHttpClient.layer.pipe(
61
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
62
+ ),
63
+ };
64
+ };
65
+
66
+ // MEASURED 2026-09-24, live TB4 `GET /nodes/n2/disks/zfs/rpool`.
67
+ const live = {
68
+ children: [
69
+ { children: [{ name: 'nvme-part3', state: 'ONLINE' }], name: 'rpool', state: 'ONLINE' },
70
+ ],
71
+ errors: 'No known data errors',
72
+ name: 'rpool',
73
+ state: 'ONLINE',
74
+ };
75
+ // ⚠️ ADOPT-ONLY (no `devices`/`raidlevel`, decision 9) — the pool is not one this test creates.
76
+ const declare = () =>
77
+ ProxmoxZfsPool('n2-rpool', { name: 'rpool', node: 'n2', target: FAKE_TARGET });
78
+
79
+ describe("Proxmox.ZfsPool's own cries-wolf fix: a refused read mint reports noop, never update", () => {
80
+ test('an already-adopted pool plans noop once the credential is denied, and nothing is written', async () => {
81
+ const fake = clusterOverPool('n2', 'rpool', live);
82
+ await withoutBao(async () => {
83
+ const engine = engineOver(ProxmoxZfsPoolProvider().pipe(Layer.provideMerge(fake.layer)));
84
+ expect(await engine.deploy(declare())).toEqual({ 'n2-rpool': 'adopted' });
85
+ fake.denyRead();
86
+ const report = await engine.verify(declare(), { all: true });
87
+ // ⛔ THE BUG THIS PROVES FIXED — same class as storage.ts's, measured for that family
88
+ // 2026-09-24: before the dual-path fix this row read `update` with nothing compared.
89
+ expect(report.rows[0]).toMatchObject({ diff: 'noop' });
90
+ expect(await engine.deploy(declare())).toEqual({ 'n2-rpool': 'noop' });
91
+ });
92
+ });
93
+ });
94
+
95
+ describe('the same request-shape and transient-failure fixes, once for Proxmox.ZfsPool', () => {
96
+ test('diff() propagates a transient read failure once a state row exists', async () => {
97
+ const fake = clusterOverPool('n2', 'rpool', live);
98
+ await withoutBao(async () => {
99
+ const engine = engineOver(ProxmoxZfsPoolProvider().pipe(Layer.provideMerge(fake.layer)));
100
+ expect(await engine.deploy(declare())).toEqual({ 'n2-rpool': 'adopted' });
101
+ fake.breakReads();
102
+ // ⛔ THE FIX: this must REJECT, never resolve with a false 'update'.
103
+ await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
104
+ });
105
+ });
106
+
107
+ test('alchemy drift never reports a transient failure as missing', async () => {
108
+ const fake = clusterOverPool('n2', 'rpool', live);
109
+ await withoutBao(async () => {
110
+ const engine = engineOver(ProxmoxZfsPoolProvider().pipe(Layer.provideMerge(fake.layer)));
111
+ expect(await engine.deploy(declare())).toEqual({ 'n2-rpool': 'adopted' });
112
+ fake.breakReads();
113
+ // ⛔ THE FIX: this must REJECT. Without `read`'s `output` branch it resolved with
114
+ // `{ 'n2-rpool': { action: 'missing' } }` and no error anywhere.
115
+ await expect(engine.drift(declare())).rejects.toBeDefined();
116
+ });
117
+ });
118
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `Proxmox.ZfsPool`'s READ side: the live-to-attributes shape, the read itself, and `matches`.
3
+ * Split out of zfs-pool.ts (2026-09-24, the distilled migration) — the storage.ts/storage-wire.ts
4
+ * seam; the write side is zfs-pool-form.ts.
5
+ */
6
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
7
+ import * as Effect from 'effect/Effect';
8
+ import { runPve } from './distilled-pve.ts';
9
+ import type { ZfsPoolProps } from './zfs-pool.ts';
10
+ import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
11
+ import { text } from './values.ts';
12
+
13
+ /**
14
+ * ⚠️ EVERYTHING HERE IS REPORTED, NOTHING HERE IS COMPARED — see the ⛔ on `matches` below. These
15
+ * exist so a plan and the state store can say what the pool actually is, on a family where the
16
+ * declaration and the live object share no comparable field at all.
17
+ */
18
+ export interface ZfsPoolAttributes {
19
+ /** ★ The value a `Proxmox.Storage` should read to order itself after this pool. */
20
+ name: string;
21
+ node: string;
22
+ /** `ONLINE` | `DEGRADED` | `FAULTED` | … Health, not configuration. */
23
+ state: string;
24
+ /** ZFS's own error summary, e.g. `No known data errors`. */
25
+ errors: string;
26
+ /**
27
+ * The leaf devices ZFS reports, in vdev order, comma-joined.
28
+ *
29
+ * ⚠️ THIS IS WHAT ZFS RESOLVED, NOT WHAT WAS DECLARED, and the two normally differ: PVE rewrites
30
+ * a `/dev/sdb` into a by-id link before creating, and ZFS reports partition paths (`…-part3`)
31
+ * for a pool built on partitions — MEASURED, that is exactly what node-b's `rpool` reports.
32
+ * Never compare it with `props.devices`.
33
+ */
34
+ devices: string;
35
+ }
36
+
37
+ /**
38
+ * The device paths at the bottom of PVE's vdev tree, in the order ZFS reports them.
39
+ *
40
+ * ⚠️ "NO CHILDREN" IS THE LEAF TEST, AND IT IS NOT A GUESS — MEASURED in the node's `preparetree`,
41
+ * which sets `leaf` to 0 when there are children and 1 otherwise. Reading the flag would work
42
+ * equally well; recursing on `children` needs no second field to be present and is what makes
43
+ * nested sections (mirrors inside a raid10, `spares`, `cache`) flatten correctly.
44
+ */
45
+ export const leafDevices = (children: unknown): string[] =>
46
+ Array.isArray(children)
47
+ ? children.flatMap((entry: unknown) => {
48
+ const vdev = entry as { children?: unknown; name?: unknown };
49
+ const nested = leafDevices(vdev.children);
50
+ return nested.length > 0 ? nested : [text(vdev.name)].filter((name) => name !== '');
51
+ })
52
+ : [];
53
+
54
+ /**
55
+ * ⚠️ `scan`, `status` AND `action` ARE DELIBERATELY NOT ATTRIBUTES. All three are advisory strings
56
+ * ZFS rewrites on its own — `scan` on every scrub, the other two as feature flags and faults come
57
+ * and go — so keeping them would rewrite this resource's state row for reasons no declaration
58
+ * caused. Same reasoning as `digest` in storage.ts.
59
+ */
60
+ const attributesOf = (
61
+ live: nodes.GetNodeDiskZfsResponse,
62
+ props: ZfsPoolProps,
63
+ ): ZfsPoolAttributes => ({
64
+ devices: leafDevices(live.children).join(','),
65
+ errors: text(live.errors, 'unknown'),
66
+ name: props.name,
67
+ node: props.node,
68
+ state: text(live.state, 'unknown'),
69
+ });
70
+
71
+ /**
72
+ * ★ Default `read` role — `Sys.Audit` on `/` is all this GET checks (zfs-pool.ts's header).
73
+ * ⛔ `{ node: props.node, name: props.name }`, NEVER THE WHOLE `props` — storage.ts's/user.ts's own
74
+ * ⛔, the kit 0.31.1 regression: `GetNodeDiskZfsRequest`'s schema declares only `node` and `name`
75
+ * (both path labels); any OTHER key on the object passed to `nodes.getNodeDiskZfs` is treated by
76
+ * distilled's `buildRequest` as an "unknown key" and JSON-encoded onto this bodyless GET, which a
77
+ * stricter fetch client refuses outright.
78
+ * ⛔ A MISSING POOL IS A 500, NOT A 404 — MEASURED against the live cluster (TB4 n2, 2026-09-24,
79
+ * read-role, read-only probe): `GET /nodes/n2/disks/zfs/hf-measure-nonexistent-pool` answers
80
+ * `{"message":"command '/sbin/zpool status -P hf-measure-nonexistent-pool' failed: exit code
81
+ * 1\n","data":null}` at HTTP 500 — PVE shells out to `zpool status` and reports its exit code
82
+ * as a generic command failure, not a typed "not found". The same shape as user.ts's/group.ts's/
83
+ * storage.ts's own measured 500s. `readPool` below (used by `createPool`'s settle-poll and by
84
+ * `reconcile`/`read`) still folds it, exactly as `readUser`/`readGroup`/`readStorage` do — the
85
+ * whole reason `createPool` can tell "not built yet" from "forbidden" only by giving up after
86
+ * `SETTLE_ATTEMPTS` (zfs-pool-form.ts's own ⚠️ on that); `readPoolOrFail` (used by `diff`) does
87
+ * not fold it, so a genuine transient failure there propagates and fails the plan loudly instead.
88
+ */
89
+ export const readPoolOrFail = (props: ZfsPoolProps) =>
90
+ readOrUnreadable(
91
+ runPve(
92
+ props.target,
93
+ 'read',
94
+ false,
95
+ nodes.getNodeDiskZfs({ name: props.name, node: props.node }),
96
+ ),
97
+ ).pipe(
98
+ Effect.map((live) => {
99
+ if (live === UNREADABLE) return UNREADABLE;
100
+ // ⚠️ A detail answer with no `name` is not a pool — same shape check zfs-pool-spec.ts made,
101
+ // kept as a defensive fallback now that the 500 above is the measured absence signal.
102
+ return text(live.name) === '' ? undefined : attributesOf(live, props);
103
+ }),
104
+ );
105
+
106
+ /**
107
+ * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `createPool`'s settle-poll AND
108
+ * `read`/`reconcile` MAY USE IT — user.ts's `readUser` has the full reasoning. Safe here because
109
+ * a wrongful fold during create costs at most a redundant `createNodeDiskZfs` POST, which PVE
110
+ * refuses loudly ("pool already exists") rather than silently corrupting anything — zfs-pool.ts's
111
+ * own header, measured.
112
+ * ⛔ `zfs-pool.ts`'s `diff` does **NOT** use this — the same bug class as the credential denial fix:
113
+ * folding a TRANSIENT failure into "absent" at PLAN TIME would force a false `update` with
114
+ * nothing compared. `diff` calls `readPoolOrFail` directly, so a genuine failure there propagates
115
+ * and fails the whole plan loudly instead.
116
+ */
117
+ export const readPool = (props: ZfsPoolProps) =>
118
+ readPoolOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
119
+
120
+ /** `read`/`reconcile`/`createPool` return `Attributes | undefined`; only `diff` sees `UNREADABLE`. */
121
+ export const dropUnreadable = (live: ZfsPoolAttributes | Unreadable | undefined) =>
122
+ live === UNREADABLE ? undefined : live;
123
+
124
+ /**
125
+ * ⛔ THERE IS NO `matches` FUNCTION HERE — UNLIKE EVERY OTHER MIGRATED FAMILY, AND DELIBERATELY.
126
+ * Every create parameter on this family is write-only (this file's own `attributesOf`), so
127
+ * there is nothing a declaration and a live pool can ever be compared on — a `matches` that
128
+ * invented one would plan a replace (`zpool destroy`, since this family has no PUT) over a
129
+ * difference it could never verify. `zfs-pool.ts`'s `diff` returns `noop` unconditionally once a
130
+ * pool is found, rather than calling a function that would only ever return `true` — found by
131
+ * adversarial review that an earlier draft exported one here anyway, unused by anything.
132
+ */