@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.
- package/README.md +3 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +53 -1
- package/dist/launchd/index.js.map +4 -3
- package/dist/launchd/port-claims.d.ts +31 -0
- package/dist/launchd/port-claims.d.ts.map +1 -0
- package/dist/proxmox/api-token-form.d.ts +12 -1
- package/dist/proxmox/api-token-form.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts +8 -3
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts +13 -13
- package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +9 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +17 -9
- package/dist/proxmox/index.js.map +8 -7
- package/dist/proxmox/zfs-pool-spec.d.ts +27 -0
- package/dist/proxmox/zfs-pool-spec.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-write.d.ts +48 -7
- package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool.d.ts +42 -20
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/release/catalog.d.ts +37 -0
- package/dist/release/catalog.d.ts.map +1 -1
- package/dist/release/index.d.ts +2 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +75 -15
- package/dist/release/index.js.map +5 -4
- package/dist/release/openbao.d.ts +35 -0
- package/dist/release/openbao.d.ts.map +1 -0
- package/docs/adopted-deploys.md +14 -0
- package/docs/launchd-ports.md +109 -0
- package/docs/launchd.md +5 -0
- package/docs/release-binary-catalogs.md +32 -7
- package/docs/release-binary-openbao.md +151 -0
- package/docs/release-binary.md +13 -2
- package/package.json +1 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/port-claims.test.ts +124 -0
- package/src/launchd/port-claims.ts +102 -0
- package/src/launchd/providers.test.ts +3 -0
- package/src/proxmox/api-token-adopt.test.ts +138 -0
- package/src/proxmox/api-token-form.ts +19 -2
- package/src/proxmox/api-token.ts +8 -3
- package/src/proxmox/generated/pve/cluster-sdn-fabrics.ts +23 -13
- package/src/proxmox/index.test.ts +5 -2
- package/src/proxmox/index.ts +9 -2
- package/src/proxmox/zfs-pool-adopt.test.ts +141 -0
- package/src/proxmox/zfs-pool-spec.ts +76 -0
- package/src/proxmox/zfs-pool-write.ts +63 -9
- package/src/proxmox/zfs-pool.ts +45 -95
- package/src/release/catalog.test.ts +71 -0
- package/src/release/catalog.ts +68 -5
- package/src/release/fixtures/openbao/checksums.txt +83 -0
- package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
- package/src/release/fixtures/openbao/darwin-assets.txt +12 -0
- package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +109 -0
- package/src/release/index.ts +3 -0
- package/src/release/openbao.test.ts +120 -0
- package/src/release/openbao.ts +86 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
177
|
+
delete?: readonly ('interfaces' | 'ip' | 'ip6' | 'allowed_ips' | 'endpoint' | 'peers')[];
|
|
168
178
|
digest?: string;
|
|
169
179
|
endpoint?: string;
|
|
170
|
-
interfaces
|
|
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
|
|
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
|
|
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
|
});
|
package/src/proxmox/index.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* `defaultRemovalPolicy: 'retain'`
|
|
134
|
-
*
|
|
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
|