@homeflare/alchemy 0.21.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.
Files changed (62) hide show
  1. package/README.md +3 -1
  2. package/dist/launchd/index.d.ts +2 -0
  3. package/dist/launchd/index.d.ts.map +1 -1
  4. package/dist/launchd/index.js +53 -1
  5. package/dist/launchd/index.js.map +4 -3
  6. package/dist/launchd/port-claims.d.ts +31 -0
  7. package/dist/launchd/port-claims.d.ts.map +1 -0
  8. package/dist/proxmox/api-token-form.d.ts +12 -1
  9. package/dist/proxmox/api-token-form.d.ts.map +1 -1
  10. package/dist/proxmox/api-token.d.ts +8 -3
  11. package/dist/proxmox/api-token.d.ts.map +1 -1
  12. package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts +13 -13
  13. package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts.map +1 -1
  14. package/dist/proxmox/index.d.ts +9 -2
  15. package/dist/proxmox/index.d.ts.map +1 -1
  16. package/dist/proxmox/index.js +17 -9
  17. package/dist/proxmox/index.js.map +8 -7
  18. package/dist/proxmox/zfs-pool-spec.d.ts +27 -0
  19. package/dist/proxmox/zfs-pool-spec.d.ts.map +1 -0
  20. package/dist/proxmox/zfs-pool-write.d.ts +48 -7
  21. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
  22. package/dist/proxmox/zfs-pool.d.ts +42 -20
  23. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  24. package/dist/release/catalog.d.ts +37 -0
  25. package/dist/release/catalog.d.ts.map +1 -1
  26. package/dist/release/index.d.ts +2 -1
  27. package/dist/release/index.d.ts.map +1 -1
  28. package/dist/release/index.js +75 -15
  29. package/dist/release/index.js.map +5 -4
  30. package/dist/release/openbao.d.ts +35 -0
  31. package/dist/release/openbao.d.ts.map +1 -0
  32. package/docs/adopted-deploys.md +14 -0
  33. package/docs/launchd-ports.md +109 -0
  34. package/docs/launchd.md +5 -0
  35. package/docs/release-binary-catalogs.md +32 -7
  36. package/docs/release-binary-openbao.md +151 -0
  37. package/docs/release-binary.md +13 -2
  38. package/package.json +1 -1
  39. package/src/launchd/index.ts +2 -0
  40. package/src/launchd/port-claims.test.ts +124 -0
  41. package/src/launchd/port-claims.ts +102 -0
  42. package/src/launchd/providers.test.ts +3 -0
  43. package/src/proxmox/api-token-adopt.test.ts +138 -0
  44. package/src/proxmox/api-token-form.ts +19 -2
  45. package/src/proxmox/api-token.ts +8 -3
  46. package/src/proxmox/generated/pve/cluster-sdn-fabrics.ts +23 -13
  47. package/src/proxmox/index.test.ts +5 -2
  48. package/src/proxmox/index.ts +9 -2
  49. package/src/proxmox/zfs-pool-adopt.test.ts +141 -0
  50. package/src/proxmox/zfs-pool-spec.ts +76 -0
  51. package/src/proxmox/zfs-pool-write.ts +63 -9
  52. package/src/proxmox/zfs-pool.ts +45 -95
  53. package/src/release/catalog.test.ts +71 -0
  54. package/src/release/catalog.ts +68 -5
  55. package/src/release/fixtures/openbao/checksums.txt +83 -0
  56. package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
  57. package/src/release/fixtures/openbao/darwin-assets.txt +12 -0
  58. package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +109 -0
  59. package/src/release/index.ts +3 -0
  60. package/src/release/openbao.test.ts +120 -0
  61. package/src/release/openbao.ts +86 -0
  62. package/src/release/providers.test.ts +1 -0
@@ -29,7 +29,7 @@ export type ClusterSdnFabricsFabricGetReturn = readonly ({
29
29
  'lock-token'?: string;
30
30
  persistent_keepalive?: number;
31
31
  protocol: 'openfabric' | 'ospf' | 'wireguard' | 'bgp';
32
- redistribute: readonly unknown[];
32
+ redistribute?: readonly string[];
33
33
  route_filter?: string;
34
34
  } & Record<string, unknown>)[];
35
35
 
@@ -45,7 +45,7 @@ export type ClusterSdnFabricsFabricPostParams = {
45
45
  'lock-token'?: string;
46
46
  persistent_keepalive?: `${number}`;
47
47
  protocol: 'openfabric' | 'ospf' | 'wireguard' | 'bgp';
48
- redistribute: readonly string[];
48
+ redistribute?: readonly string[];
49
49
  route_filter?: string;
50
50
  };
51
51
  /** POST /cluster/sdn/fabrics/fabric — `data` payload after client unwrap. */
@@ -63,7 +63,7 @@ export type ClusterSdnFabricsFabricIdGetReturn = {
63
63
  'lock-token'?: string;
64
64
  persistent_keepalive?: number;
65
65
  protocol: 'openfabric' | 'ospf' | 'wireguard' | 'bgp';
66
- redistribute: readonly unknown[];
66
+ redistribute?: readonly string[];
67
67
  route_filter?: string;
68
68
  } & Record<string, unknown>;
69
69
 
@@ -71,7 +71,17 @@ export type ClusterSdnFabricsFabricIdGetReturn = {
71
71
  export type ClusterSdnFabricsFabricIdPutParams = {
72
72
  area?: string;
73
73
  csnp_interval?: `${number}`;
74
- delete: readonly string[];
74
+ delete?: readonly (
75
+ | 'ip_prefix'
76
+ | 'ip6_prefix'
77
+ | 'hello_interval'
78
+ | 'csnp_interval'
79
+ | 'route_filter'
80
+ | 'redistribute'
81
+ | 'route_map_in'
82
+ | 'route_map_out'
83
+ | 'area'
84
+ | 'persistent_keepalive')[];
75
85
  digest?: string;
76
86
  hello_interval?: `${number}`;
77
87
  ip6_prefix?: string;
@@ -79,7 +89,7 @@ export type ClusterSdnFabricsFabricIdPutParams = {
79
89
  'lock-token'?: string;
80
90
  persistent_keepalive?: `${number}`;
81
91
  protocol: 'openfabric' | 'ospf' | 'wireguard' | 'bgp';
82
- redistribute: readonly string[];
92
+ redistribute?: readonly string[];
83
93
  route_filter?: string;
84
94
  };
85
95
  /** PUT /cluster/sdn/fabrics/fabric/{id} — `data` payload after client unwrap. */
@@ -96,7 +106,7 @@ export type ClusterSdnFabricsNodeGetReturn = readonly ({
96
106
  digest?: string;
97
107
  endpoint?: string;
98
108
  fabric_id: string;
99
- interfaces: readonly unknown[];
109
+ interfaces?: readonly string[];
100
110
  ip?: string;
101
111
  ip6?: string;
102
112
  'lock-token'?: string;
@@ -115,7 +125,7 @@ export type ClusterSdnFabricsNodeFabric_idGetReturn = readonly ({
115
125
  digest?: string;
116
126
  endpoint?: string;
117
127
  fabric_id: string;
118
- interfaces: readonly unknown[];
128
+ interfaces?: readonly string[];
119
129
  ip?: string;
120
130
  ip6?: string;
121
131
  'lock-token'?: string;
@@ -131,7 +141,7 @@ export type ClusterSdnFabricsNodeFabric_idPostParams = {
131
141
  allowed_ips?: readonly string[];
132
142
  digest?: string;
133
143
  endpoint?: string;
134
- interfaces: readonly string[];
144
+ interfaces?: readonly string[];
135
145
  ip?: string;
136
146
  ip6?: string;
137
147
  'lock-token'?: string;
@@ -150,7 +160,7 @@ export type ClusterSdnFabricsNodeFabric_idNode_idGetReturn = {
150
160
  digest?: string;
151
161
  endpoint?: string;
152
162
  fabric_id: string;
153
- interfaces: readonly unknown[];
163
+ interfaces?: readonly string[];
154
164
  ip?: string;
155
165
  ip6?: string;
156
166
  'lock-token'?: string;
@@ -164,10 +174,10 @@ export type ClusterSdnFabricsNodeFabric_idNode_idGetReturn = {
164
174
  /** PUT /cluster/sdn/fabrics/node/{fabric_id}/{node_id} — form/query parameters (path segments omitted). */
165
175
  export type ClusterSdnFabricsNodeFabric_idNode_idPutParams = {
166
176
  allowed_ips?: readonly string[];
167
- delete: readonly string[];
177
+ delete?: readonly ('interfaces' | 'ip' | 'ip6' | 'allowed_ips' | 'endpoint' | 'peers')[];
168
178
  digest?: string;
169
179
  endpoint?: string;
170
- interfaces: readonly string[];
180
+ interfaces?: readonly string[];
171
181
  ip?: string;
172
182
  ip6?: string;
173
183
  'lock-token'?: string;
@@ -197,7 +207,7 @@ export type ClusterSdnFabricsAllGetReturn = {
197
207
  'lock-token'?: string;
198
208
  persistent_keepalive?: number;
199
209
  protocol: 'openfabric' | 'ospf' | 'wireguard' | 'bgp';
200
- redistribute: readonly unknown[];
210
+ redistribute?: readonly string[];
201
211
  route_filter?: string;
202
212
  } & Record<string, unknown>)[];
203
213
  nodes: readonly ({
@@ -205,7 +215,7 @@ export type ClusterSdnFabricsAllGetReturn = {
205
215
  digest?: string;
206
216
  endpoint?: string;
207
217
  fabric_id: string;
208
- interfaces: readonly unknown[];
218
+ interfaces?: readonly string[];
209
219
  ip?: string;
210
220
  ip6?: string;
211
221
  'lock-token'?: string;
@@ -7,6 +7,9 @@
7
7
  * ★ CephDaemon, CephFs and CephOsd are Resources since 2026-09-22: adopt-only by shape and retain
8
8
  * on destroy (ceph-adopt.test.ts). ⛔ CephFlag stays Provider-only: a declared flag reasserts a
9
9
  * maintenance toggle on every deploy (ceph-flag.ts).
10
+ * ★ ApiToken and ZfsPool are Resources since 2026-09-23, decision 9: ApiToken metadata-only,
11
+ * ZfsPool adopt-only when `devices`/`raidlevel` are left undeclared (api-token-adopt.test.ts,
12
+ * zfs-pool-adopt.test.ts).
10
13
  */
11
14
  import { expect, test } from 'bun:test';
12
15
  import { PbsDatastore, ProxmoxStorage } from './index.ts';
@@ -21,6 +24,7 @@ const resourceExports = [
21
24
  'PbsSyncJob',
22
25
  'PbsVerifyJob',
23
26
  'ProxmoxAcl',
27
+ 'ProxmoxApiToken',
24
28
  'ProxmoxBackupJob',
25
29
  'ProxmoxCephDaemon',
26
30
  'ProxmoxCephFs',
@@ -39,6 +43,7 @@ const resourceExports = [
39
43
  'ProxmoxSdnZone',
40
44
  'ProxmoxStorage',
41
45
  'ProxmoxUser',
46
+ 'ProxmoxZfsPool',
42
47
  ] as const;
43
48
 
44
49
  test('control-plane Resource constructors are on the public barrel', () => {
@@ -60,7 +65,5 @@ test('QEMU, NIC apply and the Ceph flag stay Provider-only', () => {
60
65
  expect(src).not.toMatch(/export \{ ProxmoxVm[, }]/);
61
66
  expect(src).not.toMatch(/export \{ ProxmoxNodeNetwork[, }]/);
62
67
  expect(src).not.toMatch(/export \{ ProxmoxNetworkApply[, }]/);
63
- expect(src).not.toMatch(/export \{ ProxmoxApiToken[, }]/);
64
- expect(src).not.toMatch(/export \{ ProxmoxZfsPool[, }]/);
65
68
  expect(src).not.toMatch(/export \{ ProxmoxCephFlag[, }]/);
66
69
  });
@@ -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