@homeflare/alchemy 0.22.0 → 0.23.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.
@@ -33,12 +33,19 @@
33
33
  * including one run for an unrelated resource in the middle of a maintenance window
34
34
  * (ceph-flag.ts has the whole argument). A stack that wants a flag set must reach for the
35
35
  * Provider deliberately; the barrel does not offer the sentence by default.
36
+ * ★ `ProxmoxApiToken` AND `ProxmoxZfsPool` JOINED 2026-09-23, PER TIM'S DECISION 9: the kit builds
37
+ * an ApiToken export that is metadata-only (`comment`/`expire`/`privsep` — `reconcile` refuses
38
+ * to mint a secret it cannot store, api-token.ts) and a ZfsPool export that is adopt-only when
39
+ * `devices`/`raidlevel` are left undeclared (for a pool PVE's own schema cannot fully describe,
40
+ * such as a stripe — zfs-pool.ts). Neither can CREATE what it does not already have devices and
41
+ * a raidlevel, or a live token, to build from. Pinned by api-token-adopt.test.ts and
42
+ * zfs-pool-adopt.test.ts.
36
43
  * ★ Anything else unlisted is still reachable by path if you genuinely need
37
44
  * it — that is a deliberate, visible act rather than an accident of barrelling.
38
45
  */
39
46
  export { ProxmoxAcl, ProxmoxAclProvider } from './acl.ts';
40
47
  export { type AlertmanagerBodyOptions, alertmanagerAlertBody } from './alertmanager-body.ts';
41
- export { ProxmoxApiTokenProvider } from './api-token.ts';
48
+ export { ProxmoxApiToken, ProxmoxApiTokenProvider } from './api-token.ts';
42
49
  export { ProxmoxBackupJob, ProxmoxBackupJobProvider } from './backup-job.ts';
43
50
  export { ProxmoxCephDaemon, ProxmoxCephDaemonProvider } from './ceph-daemon.ts';
44
51
  export { ProxmoxCephFlagProvider } from './ceph-flag.ts';
@@ -105,4 +112,4 @@ export { ProxmoxSdnZone, ProxmoxSdnZoneProvider } from './sdn-zone.ts';
105
112
  export { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
106
113
  export { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
107
114
  export { type FromEnv } from '../secrets/write-only.ts';
108
- export { ProxmoxZfsPoolProvider } from './zfs-pool.ts';
115
+ export { ProxmoxZfsPool, ProxmoxZfsPoolProvider } from './zfs-pool.ts';
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `Proxmox.ZfsPool` adopt-only mode (decision 9, 2026-09-23) through Alchemy's own Plan and
3
+ * Apply, over a fake cluster. Adopt-only means `devices` and `raidlevel` are both left
4
+ * undeclared — the only shape available for a pool PVE's own POST schema cannot fully describe,
5
+ * such as n1's `speed`, whose layout is a stripe (decision 14 accepts it as-is).
6
+ *
7
+ * 🔴 THE BUG THIS PINS (measured 2026-09-23, before this change). `createForm` called
8
+ * `props.devices.join(',')` unconditionally, so the plan-time create guard — which evaluates
9
+ * `spec.createForm(props)` eagerly, before it even looks at `presence` (resource-guard.ts) —
10
+ * threw `TypeError: Cannot read properties of undefined (reading 'join')` for every adopt-only
11
+ * declaration, before the pool was ever read. Reverting `createForm`/`zfsPoolEndpoint` in
12
+ * zfs-pool-write.ts locally reproduces it: the first test below dies with that TypeError
13
+ * instead of adopting.
14
+ * ⛔ WHAT A MISTAKE HERE WOULD COST ON A REAL CLUSTER: a POST that reached the wire without
15
+ * `devices`/`raidlevel` would be refused by PVE's own schema, but only after leaving this
16
+ * process; a DELETE reaching the wire on an undeclared line would run `zpool destroy`. So the
17
+ * no-create and no-delete tests both end on `fake.writes()` empty, and the mutation row proves
18
+ * the harness can still see a write when one is meant to happen.
19
+ */
20
+ import { describe, expect, test } from 'bun:test';
21
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
22
+ import * as Effect from 'effect/Effect';
23
+ import * as Layer from 'effect/Layer';
24
+ import { engineOver } from '../verify/fake-engine.ts';
25
+ import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
26
+ import { ProxmoxZfsPool, ProxmoxZfsPoolProvider, type ZfsRaidLevel } from './zfs-pool.ts';
27
+
28
+ /** rpool on three nodes, plus rpool and a stripe `speed` on a fourth — n1's shape, placeholders. */
29
+ const FIVE: readonly (readonly [string, string])[] = [
30
+ ['node-b', 'rpool'],
31
+ ['node-c', 'rpool'],
32
+ ['node-d', 'rpool'],
33
+ ['node-e', 'rpool'],
34
+ ['node-e', 'speed'],
35
+ ];
36
+
37
+ const key = (node: string, name: string) => `${node}/${name}`;
38
+
39
+ /** Only the fields `zfs-pool-spec.ts`'s `attributes` reads; `name` non-empty is what "found" means. */
40
+ const poolAnswer = (name: string): Record<string, unknown> => ({
41
+ children: [],
42
+ errors: 'No known data errors',
43
+ name,
44
+ state: 'ONLINE',
45
+ });
46
+
47
+ /** A cluster holding exactly `present`; a DELETE removes its key, as `zpool destroy` would. */
48
+ const cluster = (present: Set<string>) =>
49
+ fakePve((call: PveCall) => {
50
+ const hit = /^nodes\/([^/]+)\/disks\/zfs\/([^/]+)$/.exec(call.path);
51
+ if (hit === null) return undefined;
52
+ const [, node = '', name = ''] = hit;
53
+ if (call.method === 'GET') return present.has(key(node, name)) ? poolAnswer(name) : undefined;
54
+ if (call.method === 'DELETE') {
55
+ present.delete(key(node, name));
56
+ return null;
57
+ }
58
+ return undefined;
59
+ });
60
+
61
+ /** ⚠️ NO `devices`/`raidlevel` — the whole point of this file. */
62
+ const adoptOnly = (node: string, name: string) =>
63
+ ProxmoxZfsPool(`${node}-${name}`, { name, node, target: FAKE_TARGET });
64
+
65
+ const allFive = () => Effect.all(FIVE.map(([node, name]) => adoptOnly(node, name)));
66
+ const presentFive = () => new Set(FIVE.map(([node, name]) => key(node, name)));
67
+
68
+ const engineFor = (fake: ReturnType<typeof cluster>) =>
69
+ engineOver(ProxmoxZfsPoolProvider().pipe(Layer.provideMerge(fake.layer)));
70
+
71
+ describe('adopting an adopt-only ZfsPool declaration', () => {
72
+ test('rpool on three nodes, plus rpool and a stripe speed on a fourth: noop, adopted, no writes', async () => {
73
+ const fake = cluster(presentFive());
74
+ await withoutBao(async () => {
75
+ const engine = engineFor(fake);
76
+ const report = await engine.verify(allFive());
77
+ expect(report.rows).toHaveLength(5);
78
+ for (const row of report.rows) {
79
+ expect(row).toMatchObject({ changed: [], diff: 'noop', ok: true, planned: 'adopted' });
80
+ }
81
+ const planned = await engine.deploy(allFive());
82
+ expect(new Set(Object.values(planned))).toEqual(new Set(['adopted']));
83
+ expect(Object.keys(planned)).toHaveLength(5);
84
+ });
85
+ expect(fake.writes()).toEqual([]);
86
+ });
87
+
88
+ test('undeclaring all five drops the state rows and sends no DELETE (retain is the default)', async () => {
89
+ const fake = cluster(presentFive());
90
+ await withoutBao(async () => {
91
+ const engine = engineFor(fake);
92
+ await engine.deploy(allFive());
93
+ expect(engine.stored()).toContain('node-b-rpool');
94
+ expect(await engine.deploy(Effect.void)).toEqual({});
95
+ expect(engine.stored()).not.toContain('"node-b-rpool"');
96
+ });
97
+ expect(fake.writes()).toEqual([]);
98
+ });
99
+
100
+ test('an adopt-only pool that is absent: the deploy refuses, and writes nothing', async () => {
101
+ const fake = cluster(new Set());
102
+ await withoutBao(async () => {
103
+ const engine = engineFor(fake);
104
+ await expect(engine.deploy(adoptOnly('node-b', 'rpool'))).rejects.toBeDefined();
105
+ });
106
+ expect(fake.writes()).toEqual([]);
107
+ });
108
+
109
+ test('an adopted pool that vanishes: the next plan refuses at the create guard, no writes', async () => {
110
+ const present = new Set([key('node-b', 'rpool')]);
111
+ const fake = cluster(present);
112
+ await withoutBao(async () => {
113
+ const engine = engineFor(fake);
114
+ await engine.deploy(adoptOnly('node-b', 'rpool'));
115
+ present.delete(key('node-b', 'rpool')); // the pool is gone by the next deploy
116
+ await expect(engine.deploy(adoptOnly('node-b', 'rpool'))).rejects.toBeDefined();
117
+ });
118
+ expect(fake.writes()).toEqual([]);
119
+ });
120
+
121
+ /**
122
+ * ⛔ THE ONE THAT MATTERS MOST — the corrected header in zfs-pool.ts, proved. `retain` is why a
123
+ * dropped declaration above sent no DELETE; this shows the harness still sees a write when
124
+ * one is meant to happen, the way ceph-adopt.test.ts's mutation row does.
125
+ */
126
+ test('RemovalPolicy.destroy() then undeclaring sends exactly one DELETE', async () => {
127
+ const fake = cluster(new Set([key('node-b', 'rpool')]));
128
+ await withoutBao(async () => {
129
+ const engine = engineFor(fake);
130
+ await engine.deploy(adoptOnly('node-b', 'rpool').pipe(RemovalPolicy.destroy()));
131
+ await engine.deploy(Effect.void);
132
+ });
133
+ expect(fake.writes()).toEqual(['DELETE nodes/node-b/disks/zfs/rpool']);
134
+ });
135
+
136
+ test('ZfsRaidLevel has no stripe: rejected at compile time, not just at runtime', () => {
137
+ // @ts-expect-error 'stripe' is not in PVE's raidlevel enum (generated/pve.ts, decision 9)
138
+ const r: ZfsRaidLevel = 'stripe';
139
+ expect(String(r)).toBe('stripe');
140
+ });
141
+ });
@@ -0,0 +1,76 @@
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
+ };
@@ -38,20 +38,51 @@ import type { ZfsPoolAttributes, ZfsPoolProps } from './zfs-pool.ts';
38
38
  * declaration asked for, and this is the one call that writes to physical disks.
39
39
  */
40
40
  /**
41
- * The vendor rules the create form is checked against at plan time.
41
+ * The vendor rules a FULL declaration's create form is checked against at plan time.
42
42
  *
43
43
  * ⛔ HERE RATHER THAN INLINE IN zfs-pool.ts BECAUSE THAT FILE IS ALREADY OVER THE 250-LINE CAP,
44
44
  * and a `pve:POST …` literal has to live in a non-test source file for the generator to table
45
- * it at all. The spec imports it on the line it already imports `createForm` from.
45
+ * it at all. zfs-pool-spec.ts imports it on the line it already imports `createForm` from.
46
46
  * ⚠️ NO UPDATE KEY: PVE registers no PUT under `/nodes/{node}/disks/zfs`, which is exactly why
47
47
  * the spec declares no `updateForm` and a changed prop is a REPLACE.
48
+ * ⚠️ `spec.endpoint` DOES NOT RETURN THIS CONSTANT DIRECTLY — see the ★ on `zfsPoolEndpoint`
49
+ * below, and the ⛔ on `devices`/`raidlevel` in zfs-pool.ts (decision 9, 2026-09-23).
48
50
  */
49
51
  export const ZFS_POOL_ENDPOINT: EndpointPair = { create: 'pve:POST /nodes/{node}/disks/zfs' };
50
52
 
53
+ /**
54
+ * ★ `PveSpec['endpoint']` AS A FUNCTION OF PROPS, THE WAY notification-target.ts AND
55
+ * ceph-daemon.ts ALREADY DO IT FOR A DIFFERENT REASON (resource-spec.ts's ⚠️ on that field).
56
+ * Here the reason is decision 9's adopt-only mode: `guardCreate` runs on the FIRST plan of
57
+ * ANY declaration (resource.ts `ops.diff`, `output === undefined` branch) and checks the
58
+ * create form's REQUIRED fields whenever `endpoint.create` resolves to a key — and PVE marks
59
+ * both `devices` and `raidlevel` required (measured, codegen/constraints's `pve-nodes-disks`
60
+ * row). An adopt-only declaration deliberately omits both, so returning the real key there
61
+ * would refuse a MATCHING adoption at plan time, before `read` ever ran — exactly the gap
62
+ * `guardCreate`/`guardUpdate` being exported (resource.ts's ★) says this family must not fall
63
+ * through. Returning `{}` (no `create` key) makes `guardCreate` a no-op (`constraint-guard.ts`:
64
+ * `create === undefined ? Effect.void : …`) for an adopt-only declaration, while a FULL one
65
+ * (`devices` and `raidlevel` both present) is checked exactly as before.
66
+ * ⚠️ THE RUNTIME BACKSTOP IS `createPool` BELOW, NOT THIS FUNCTION. Skipping the plan-time guard
67
+ * for an adopt-only declaration does not skip the check that the pool is actually there —
68
+ * `createPool` refuses before any POST when the read comes back absent and either field is
69
+ * undefined, which is what a genuinely absent adopt-only declaration hits at apply time.
70
+ */
71
+ export const zfsPoolEndpoint = (props: ZfsPoolProps): EndpointPair =>
72
+ props.devices === undefined || props.raidlevel === undefined ? {} : ZFS_POOL_ENDPOINT;
73
+
74
+ /**
75
+ * ⚠️ EVERY KEY IS OMITTED WHEN ABSENT, `devices` AND `raidlevel` INCLUDED — not just the
76
+ * optional ones. An adopt-only declaration (both undefined, decision 9) must produce a form
77
+ * `createPool`'s own guard can inspect for their absence, and one this function itself does
78
+ * not throw building: `props.devices.join(',')` on an undefined `devices` is exactly the
79
+ * `TypeError` the plan-time guard hit before this change, because `guardCreate` evaluates
80
+ * `spec.createForm(props)` eagerly (resource-guard.ts) — before it even looks at `presence`.
81
+ */
51
82
  export const createForm = (props: ZfsPoolProps): Record<string, string> => ({
52
- devices: props.devices.join(','),
83
+ ...(props.devices === undefined ? {} : { devices: props.devices.join(',') }),
53
84
  name: props.name,
54
- raidlevel: props.raidlevel,
85
+ ...(props.raidlevel === undefined ? {} : { raidlevel: props.raidlevel }),
55
86
  ...(props.ashift === undefined ? {} : { ashift: String(props.ashift) }),
56
87
  ...(props.compression === undefined ? {} : { compression: props.compression }),
57
88
  ...(props['draid-config'] === undefined ? {} : { 'draid-config': props['draid-config'] }),
@@ -84,6 +115,17 @@ const SETTLE_ATTEMPTS = 30;
84
115
  * ⚠️ SHORT POLLS WITH A HARD CAP, NOT ONE LONG WAIT. `read` cannot tell "not yet" from "forbidden"
85
116
  * — both are `undefined` — so the loop gives up with the UPID rather than retrying forever, and
86
117
  * the UPID is the only thing that leads to the worker's real error.
118
+ *
119
+ * ⛔ THE ADOPT-ONLY GUARD, RIGHT AFTER THE READ AND BEFORE ANYTHING ELSE. Decision 9 (2026-09-23):
120
+ * omitting `devices` and `raidlevel` declares a pool this resource may only ADOPT, for a pool
121
+ * PVE's own schema cannot fully describe — n1's `speed` is a stripe, and `raidlevel`'s enum has
122
+ * no stripe (zfs-pool.ts). `zfsPoolEndpoint` above already keeps the vendor constraint guard
123
+ * from refusing such a declaration at PLAN time; this is the matching refusal at APPLY time, for
124
+ * the one case that guard cannot see — the pool genuinely is not there. MEASURED without it: a
125
+ * tolerant `createForm` sends `POST … {name: "rpool"}` with no `devices`/`raidlevel`, which PVE's
126
+ * own schema would 400 on eventually, but only after the request left this process; refusing
127
+ * here is the same `Effect.die` idiom as every other refusal in this package, and it costs
128
+ * nothing this pool did not already lack.
87
129
  */
88
130
  export const createPool = <E, R>(
89
131
  props: ZfsPoolProps,
@@ -95,6 +137,17 @@ export const createPool = <E, R>(
95
137
  const existing = yield* read(props);
96
138
  if (existing !== undefined) return existing;
97
139
 
140
+ if (props.devices === undefined || props.raidlevel === undefined) {
141
+ return yield* Effect.die(
142
+ new Error(
143
+ `${props.node}/${props.name}: this is an adopt-only declaration (no \`devices\` or ` +
144
+ '`raidlevel`) and the pool is not there to adopt. An adopt-only Proxmox.ZfsPool never ' +
145
+ 'creates -- declare both fields for a pool this resource should build, or point this ' +
146
+ 'declaration at a pool that already exists on this node.',
147
+ ),
148
+ );
149
+ }
150
+
98
151
  const upid = yield* pve<string>(
99
152
  props.target,
100
153
  'provision',
@@ -127,11 +180,12 @@ export const createPool = <E, R>(
127
180
  });
128
181
 
129
182
  /**
130
- * ⛔ `DELETE /nodes/{node}/disks/zfs/{name}` RUNS `zpool destroy` AND THE DATASETS GO WITH IT.
131
- * It is implemented rather than stubbed, because a `delete` that silently does nothing lies to
132
- * whoever reads the plan. What keeps a deleted line from destroying a pool is the resource's
133
- * `defaultRemovalPolicy: 'retain'` above — an orphaned pool is forgotten, not destroyed, and a
134
- * caller who means it says `.pipe(RemovalPolicy.destroy())`.
183
+ * ⛔ `DELETE /nodes/{node}/disks/zfs/{name}` RUNS `zpool destroy` AND THE DATASETS GO WITH IT. Per
184
+ * S14 (alchemy-provider-standard), it is implemented rather than stubbed — a `delete` that
185
+ * silently does nothing lies to whoever reads the plan — and what keeps an ordinary removed
186
+ * declaration from reaching it is the resource's `defaultRemovalPolicy: 'retain'` (zfs-pool.ts):
187
+ * an orphaned pool is forgotten, not destroyed, unless a caller opts in with
188
+ * `.pipe(RemovalPolicy.destroy())`. See the corrected header in zfs-pool.ts.
135
189
  *
136
190
  * ⚠️ `cleanup-config` IS NOT SENT. It would additionally remove the PVE storage entry pointing
137
191
  * at this pool, which is a different object with its own resource (`Proxmox.Storage`); one
@@ -6,20 +6,22 @@
6
6
  * moment the worker runs. Every other decision in this file follows from that one sentence, and
7
7
  * each of them is written out below rather than left to be inferred.
8
8
  *
9
- * ⛔ DESTROY IS REFUSED. `delete` MAKES NO API CALL — removing this resource from a stack leaves
10
- * the zpool and its data exactly where they are, and Alchemy simply forgets about them. The
11
- * asymmetry is the whole argument: an ORPHANED pool is recoverable (declare it again, or destroy
12
- * it by hand once a human has looked at which disks are in it), a DESTROYED pool is not. Alchemy
13
- * deletes for reasons that have nothing to do with anybody intending it — a line removed from a
14
- * stack file, a resource renamed so the old id is dropped, a whole stack torn down by the wrong
15
- * command, a replace triggered by a prop nobody meant to touch. Handing `zpool destroy` to any
16
- * of those is not a feature. MEASURED in the node's own source: the DELETE handler forks
17
- * `zfsremove`, which runs `zpool destroy <name>` unconditionally, and with `cleanup-disks` then
18
- * calls `wipe_blockdev` on every member. Neither flag is offered as a prop, because there is no
19
- * call here for them to reach.
20
- * ★ THE OPERATOR'S PATH IS `pvesh delete /nodes/<node>/disks/zfs/<name>`, typed by a person who
21
- * has just looked at the pool. Do not "complete the CRUD" by wiring `delete` to `ops.destroy`:
22
- * that one line turns a text edit into an unrecoverable one.
9
+ * ⛔ ★ CORRECTED 2026-09-23 — THIS PARAGRAPH PREVIOUSLY SAID "DESTROY IS REFUSED" AND "`delete`
10
+ * MAKES NO API CALL". THAT WAS FALSE: MEASURED against this file's own `handlers.delete` below,
11
+ * which calls `destroyPool`, which sends `DELETE /nodes/{node}/disks/zfs/{name}` — the node's
12
+ * own source forks `zfsremove`, which runs `zpool destroy <name>` unconditionally, and with
13
+ * `cleanup-disks` then calls `wipe_blockdev` on every member (neither flag is offered as a prop,
14
+ * because there is no call here for them to reach). `delete` is FULLY IMPLEMENTED, per S14
15
+ * (alchemy-provider-standard): a `delete` that silently does nothing lies to whoever reads the
16
+ * plan. What actually keeps an ordinary removed declaration from reaching it is
17
+ * `defaultRemovalPolicy: 'retain'` below — Alchemy SKIPS `provider.delete` entirely for an
18
+ * orphaned or destroyed resource under `retain`, so the state row drops and the zpool and its
19
+ * data are left exactly where they are. The asymmetry is still the whole argument: an ORPHANED
20
+ * pool is recoverable (declare it again, or destroy it by hand once a human has looked at which
21
+ * disks are in it), a DESTROYED pool is not — which is why `retain` is the default rather than a
22
+ * documented caution, and why `delete` runs only for a caller who opts in by name with
23
+ * `.pipe(RemovalPolicy.destroy())`. See the ★ on `defaultRemovalPolicy` in resource-spec.ts, and
24
+ * zfs-pool-adopt.test.ts's mutation row, which pins exactly one DELETE reaching the fake cluster.
23
25
  *
24
26
  * ⛔ THERE IS NO PUT ON THIS FAMILY AT ALL. MEASURED from the cluster's own published schema
25
27
  * (`/usr/share/pve-docs/api-viewer/apidoc.js`, read on node-b 2026-09-13): `/nodes/{node}/disks/zfs`
@@ -47,7 +49,8 @@
47
49
  * ⚠️ TWO HANDLERS ARE OVERRIDDEN AND THE OTHER THREE COME FROM THE FACTORY — see the ★ above
48
50
  * `handlers`. `delete` is the ⛔ above. `reconcile` is because the create is a FORKED WORKER, so
49
51
  * the factory's immediate read-back would report a successful create as a failure; the measured
50
- * detail is in the ⛔ on `createPool` in zfs-pool-create.ts.
52
+ * detail, and the adopt-only refusal decision 9 adds, are both in `createPool` in
53
+ * zfs-pool-write.ts.
51
54
  *
52
55
  * ⚠️ PRIVILEGES, FROM THE SCHEMA. Read and diff need `Sys.Audit` on `/` — both GETs check it.
53
56
  * Reconcile needs `Sys.Modify` on `/` for the POST. `Datastore.Allocate` on `/storage` is NOT
@@ -57,29 +60,23 @@
57
60
  import { Resource } from 'alchemy';
58
61
  import * as Provider from 'alchemy/Provider';
59
62
  import * as Effect from 'effect/Effect';
60
- import {
61
- type PveRequirements,
62
- type PveSpec,
63
- type WithTarget,
64
- pveHandlers,
65
- pveOperations,
66
- } from './resource.ts';
67
- import { text } from './values.ts';
68
- import { ZFS_POOL_ENDPOINT, createForm, createPool, destroyPool } from './zfs-pool-write.ts';
63
+ import type { NodesNodeDisksZfsPostParams } from './generated/pve.ts';
64
+ import { type PveRequirements, type WithTarget, pveHandlers, pveOperations } from './resource.ts';
65
+ import { spec } from './zfs-pool-spec.ts';
66
+ import { createPool, destroyPool } from './zfs-pool-write.ts';
69
67
 
70
- /** PVE's layouts. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an even one. */
71
- export type ZfsRaidLevel =
72
- | 'single'
73
- | 'mirror'
74
- | 'raid10'
75
- | 'raidz'
76
- | 'raidz2'
77
- | 'raidz3'
78
- | 'draid'
79
- | 'draid2'
80
- | 'draid3';
68
+ /**
69
+ * PVE's layouts, generated (2026-09-23) from `NodesNodeDisksZfsPostParams['raidlevel']`
70
+ * (`generated/pve.ts`, manifest `pve-apidoc` pve-manager 9.2.11 sha256 9def8f13…) rather than
71
+ * hand-typed, per decision 9. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an
72
+ * even one. ⛔ THERE IS NO `stripe` IN THIS ENUM. n1's `speed` pool is one (decision 14 accepts it
73
+ * as-is): `ZfsPoolProps` below is why a stripe pool is declarable at all — as an ADOPT-ONLY
74
+ * declaration with no `raidlevel`, never as a full one.
75
+ */
76
+ export type ZfsRaidLevel = NonNullable<NodesNodeDisksZfsPostParams['raidlevel']>;
81
77
 
82
- export type ZfsCompression = 'on' | 'off' | 'gzip' | 'lz4' | 'lzjb' | 'zle' | 'zstd';
78
+ /** Generated the same way, from `NodesNodeDisksZfsPostParams['compression']`. */
79
+ export type ZfsCompression = NonNullable<NodesNodeDisksZfsPostParams['compression']>;
83
80
 
84
81
  /**
85
82
  * ⛔ THERE IS NO `add_storage`, `cleanup-config` OR `cleanup-disks` PROP, AND THAT IS NOT AN
@@ -112,9 +109,19 @@ export interface ZfsPoolProps extends WithTarget {
112
109
  * `csv` sorts, and sorting this list silently re-pairs the mirrors into a different pool.
113
110
  * ⚠️ PVE's only guard is `assert_disk_unused`, i.e. its own `disk_is_used` over the same `used`
114
111
  * column the Disks view shows. A blank disk it considers free is wiped with no confirmation.
112
+ *
113
+ * ⛔ OPTIONAL SINCE DECISION 9 (2026-09-23), AND OMITTING IT MEANS SOMETHING SPECIFIC: leaving
114
+ * both this and `raidlevel` undeclared declares an ADOPT-ONLY pool — one this resource may
115
+ * read and report but never build. That is the only shape available for a pool PVE's own POST
116
+ * schema cannot fully describe, such as n1's `speed`, whose layout is a stripe and whose
117
+ * `raidlevel` enum (above) has no stripe to declare. `createForm` (zfs-pool-write.ts) omits
118
+ * both keys when they are absent, and `createPool` refuses before any POST if the pool it
119
+ * would need to build is not already there — an adopt-only declaration never creates. Declare
120
+ * both fields together for a pool this resource should build from scratch; declaring only one
121
+ * of the two is refused the same way, since PVE's POST requires both.
115
122
  */
116
- devices: readonly string[];
117
- raidlevel: ZfsRaidLevel;
123
+ devices?: readonly string[];
124
+ raidlevel?: ZfsRaidLevel;
118
125
  /**
119
126
  * Sector size exponent, 9–16, PVE default 12.
120
127
  *
@@ -174,63 +181,6 @@ export const ProxmoxZfsPool = Resource<ProxmoxZfsPool>('Proxmox.ZfsPool', {
174
181
  defaultRemovalPolicy: 'retain',
175
182
  });
176
183
 
177
- /**
178
- * The device paths at the bottom of PVE's vdev tree, in the order ZFS reports them.
179
- *
180
- * ⚠️ "NO CHILDREN" IS THE LEAF TEST, AND IT IS NOT A GUESS — MEASURED in the node's `preparetree`,
181
- * which sets `leaf` to 0 when there are children and 1 otherwise. Reading the flag would work
182
- * equally well; recursing on `children` needs no second field to be present and is what makes
183
- * nested sections (mirrors inside a raid10, `spares`, `cache`) flatten correctly.
184
- */
185
- const leafDevices = (children: unknown): string[] =>
186
- Array.isArray(children)
187
- ? children.flatMap((entry: unknown) => {
188
- const vdev = entry as { children?: unknown; name?: unknown };
189
- const nested = leafDevices(vdev.children);
190
- return nested.length > 0 ? nested : [text(vdev.name)].filter((name) => name !== '');
191
- })
192
- : [];
193
-
194
- const spec: PveSpec<ZfsPoolProps, ZfsPoolAttributes> = {
195
- /**
196
- * ⚠️ `scan`, `status` AND `action` ARE DELIBERATELY NOT ATTRIBUTES. All three are advisory
197
- * strings ZFS rewrites on its own — `scan` on every scrub, the other two as feature flags and
198
- * faults come and go — so keeping them would rewrite this resource's state row for reasons no
199
- * declaration caused. Same reasoning as `digest` in storage.ts.
200
- */
201
- attributes: (live, props) => {
202
- // ⚠️ A detail answer with no `name` is not a pool. `name` is non-optional in PVE's schema, so
203
- // this is a shape check rather than a default — and "absent" is the honest reading of a
204
- // reply that does not describe the object that was asked for.
205
- if (text(live['name']) === '') return undefined;
206
- return {
207
- devices: leafDevices(live['children']).join(','),
208
- errors: text(live['errors'], 'unknown'),
209
- name: props.name,
210
- node: props.node,
211
- state: text(live['state'], 'unknown'),
212
- };
213
- },
214
- collection: (props) => `nodes/${props.node}/disks/zfs`,
215
- createForm,
216
- endpoint: ZFS_POOL_ENDPOINT,
217
- /**
218
- * ⛔ IT ALWAYS ANSWERS TRUE, AND THAT IS THE POINT OF THIS FILE. A pool that is there under the
219
- * declared name on the declared node IS the declaration, because there is nothing else the two
220
- * can be compared on: every create parameter is write-only (see the ⛔ in the header) and
221
- * everything the read does return is telemetry that moves by itself. So declaring what is live
222
- * plans as `noop` — the only honest answer available, and the one the live cluster needs:
223
- * `rpool` exists on node-b, node-c and node-d with nothing about its construction readable.
224
- * ⛔ ANYTHING ADDED HERE IS A REPLACE, NOT AN UPDATE. `updateForm` is omitted because PVE has
225
- * no PUT, so `resource.ts` turns a false into `{action:'replace'}` — delete then create — on
226
- * a family whose delete is `zpool destroy`. A comparison added here would be one plan away
227
- * from destroying a pool over a field it cannot even read back. If a future PVE grows a PUT,
228
- * add `updateForm` FIRST and only then consider comparing what that PUT accepts.
229
- */
230
- matches: () => true,
231
- path: (props) => `nodes/${props.node}/disks/zfs/${props.name}`,
232
- };
233
-
234
184
  const ops = pveOperations(spec);
235
185
 
236
186
  /**