@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.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The `PveSpec` for `Proxmox.ZfsPool`: where a pool lives, what its GET reports, and the vendor
3
+ * endpoint its create form is checked against.
4
+ *
5
+ * ★ SPLIT OUT OF zfs-pool.ts TO KEEP THAT FILE UNDER THE 250-LINE CAP, and the seam is the same
6
+ * one metric-server-form.ts and zfs-pool-write.ts already cut on: zfs-pool.ts answers "what is
7
+ * a zpool and when has it changed", this file answers "what PVE's own GET/{node}/disks/zfs
8
+ * collection and item say, read into that shape". `leafDevices` moved with `spec` because it
9
+ * exists for exactly one field of `spec.attributes` and nothing else in this package calls it.
10
+ *
11
+ * ⚠️ THE `import type` BACK TO zfs-pool.ts IS A CYCLE ON PAPER ONLY — type-only, erased before
12
+ * anything runs, so the resource's public shape stays in the file that declares the resource.
13
+ * Same reasoning as zfs-pool-write.ts.
14
+ */
15
+ import type { PveSpec } from './resource.ts';
16
+ import type { ZfsPoolAttributes, ZfsPoolProps } from './zfs-pool.ts';
17
+ /**
18
+ * The device paths at the bottom of PVE's vdev tree, in the order ZFS reports them.
19
+ *
20
+ * ⚠️ "NO CHILDREN" IS THE LEAF TEST, AND IT IS NOT A GUESS — MEASURED in the node's `preparetree`,
21
+ * which sets `leaf` to 0 when there are children and 1 otherwise. Reading the flag would work
22
+ * equally well; recursing on `children` needs no second field to be present and is what makes
23
+ * nested sections (mirrors inside a raid10, `spares`, `cache`) flatten correctly.
24
+ */
25
+ export declare const leafDevices: (children: unknown) => string[];
26
+ export declare const spec: PveSpec<ZfsPoolProps, ZfsPoolAttributes>;
27
+ //# sourceMappingURL=zfs-pool-spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zfs-pool-spec.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAG7C,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAErE;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,aAAc,OAAO,KAAG,MAAM,EAO9C,CAAC;AAET,eAAO,MAAM,IAAI,EAAE,OAAO,CAAC,YAAY,EAAE,iBAAiB,CAuCzD,CAAC"}
@@ -35,15 +35,44 @@ import type { ZfsPoolAttributes, ZfsPoolProps } from './zfs-pool.ts';
35
35
  * declaration asked for, and this is the one call that writes to physical disks.
36
36
  */
37
37
  /**
38
- * The vendor rules the create form is checked against at plan time.
38
+ * The vendor rules a FULL declaration's create form is checked against at plan time.
39
39
  *
40
40
  * ⛔ HERE RATHER THAN INLINE IN zfs-pool.ts BECAUSE THAT FILE IS ALREADY OVER THE 250-LINE CAP,
41
41
  * and a `pve:POST …` literal has to live in a non-test source file for the generator to table
42
- * it at all. The spec imports it on the line it already imports `createForm` from.
42
+ * it at all. zfs-pool-spec.ts imports it on the line it already imports `createForm` from.
43
43
  * ⚠️ NO UPDATE KEY: PVE registers no PUT under `/nodes/{node}/disks/zfs`, which is exactly why
44
44
  * the spec declares no `updateForm` and a changed prop is a REPLACE.
45
+ * ⚠️ `spec.endpoint` DOES NOT RETURN THIS CONSTANT DIRECTLY — see the ★ on `zfsPoolEndpoint`
46
+ * below, and the ⛔ on `devices`/`raidlevel` in zfs-pool.ts (decision 9, 2026-09-23).
45
47
  */
46
48
  export declare const ZFS_POOL_ENDPOINT: EndpointPair;
49
+ /**
50
+ * ★ `PveSpec['endpoint']` AS A FUNCTION OF PROPS, THE WAY notification-target.ts AND
51
+ * ceph-daemon.ts ALREADY DO IT FOR A DIFFERENT REASON (resource-spec.ts's ⚠️ on that field).
52
+ * Here the reason is decision 9's adopt-only mode: `guardCreate` runs on the FIRST plan of
53
+ * ANY declaration (resource.ts `ops.diff`, `output === undefined` branch) and checks the
54
+ * create form's REQUIRED fields whenever `endpoint.create` resolves to a key — and PVE marks
55
+ * both `devices` and `raidlevel` required (measured, codegen/constraints's `pve-nodes-disks`
56
+ * row). An adopt-only declaration deliberately omits both, so returning the real key there
57
+ * would refuse a MATCHING adoption at plan time, before `read` ever ran — exactly the gap
58
+ * `guardCreate`/`guardUpdate` being exported (resource.ts's ★) says this family must not fall
59
+ * through. Returning `{}` (no `create` key) makes `guardCreate` a no-op (`constraint-guard.ts`:
60
+ * `create === undefined ? Effect.void : …`) for an adopt-only declaration, while a FULL one
61
+ * (`devices` and `raidlevel` both present) is checked exactly as before.
62
+ * ⚠️ THE RUNTIME BACKSTOP IS `createPool` BELOW, NOT THIS FUNCTION. Skipping the plan-time guard
63
+ * for an adopt-only declaration does not skip the check that the pool is actually there —
64
+ * `createPool` refuses before any POST when the read comes back absent and either field is
65
+ * undefined, which is what a genuinely absent adopt-only declaration hits at apply time.
66
+ */
67
+ export declare const zfsPoolEndpoint: (props: ZfsPoolProps) => EndpointPair;
68
+ /**
69
+ * ⚠️ EVERY KEY IS OMITTED WHEN ABSENT, `devices` AND `raidlevel` INCLUDED — not just the
70
+ * optional ones. An adopt-only declaration (both undefined, decision 9) must produce a form
71
+ * `createPool`'s own guard can inspect for their absence, and one this function itself does
72
+ * not throw building: `props.devices.join(',')` on an undefined `devices` is exactly the
73
+ * `TypeError` the plan-time guard hit before this change, because `guardCreate` evaluates
74
+ * `spec.createForm(props)` eagerly (resource-guard.ts) — before it even looks at `presence`.
75
+ */
47
76
  export declare const createForm: (props: ZfsPoolProps) => Record<string, string>;
48
77
  /**
49
78
  * Create the pool if it is absent, and otherwise leave it completely alone.
@@ -69,14 +98,26 @@ export declare const createForm: (props: ZfsPoolProps) => Record<string, string>
69
98
  * ⚠️ SHORT POLLS WITH A HARD CAP, NOT ONE LONG WAIT. `read` cannot tell "not yet" from "forbidden"
70
99
  * — both are `undefined` — so the loop gives up with the UPID rather than retrying forever, and
71
100
  * the UPID is the only thing that leads to the worker's real error.
101
+ *
102
+ * ⛔ THE ADOPT-ONLY GUARD, RIGHT AFTER THE READ AND BEFORE ANYTHING ELSE. Decision 9 (2026-09-23):
103
+ * omitting `devices` and `raidlevel` declares a pool this resource may only ADOPT, for a pool
104
+ * PVE's own schema cannot fully describe — n1's `speed` is a stripe, and `raidlevel`'s enum has
105
+ * no stripe (zfs-pool.ts). `zfsPoolEndpoint` above already keeps the vendor constraint guard
106
+ * from refusing such a declaration at PLAN time; this is the matching refusal at APPLY time, for
107
+ * the one case that guard cannot see — the pool genuinely is not there. MEASURED without it: a
108
+ * tolerant `createForm` sends `POST … {name: "rpool"}` with no `devices`/`raidlevel`, which PVE's
109
+ * own schema would 400 on eventually, but only after the request left this process; refusing
110
+ * here is the same `Effect.die` idiom as every other refusal in this package, and it costs
111
+ * nothing this pool did not already lack.
72
112
  */
73
113
  export declare const createPool: <E, R>(props: ZfsPoolProps, collection: string, path: string, read: (props: ZfsPoolProps) => Effect.Effect<ZfsPoolAttributes | undefined, E, R>) => Effect.Effect<ZfsPoolAttributes, E | Error, R | import("effect/unstable/http/HttpClient").HttpClient>;
74
114
  /**
75
- * ⛔ `DELETE /nodes/{node}/disks/zfs/{name}` RUNS `zpool destroy` AND THE DATASETS GO WITH IT.
76
- * It is implemented rather than stubbed, because a `delete` that silently does nothing lies to
77
- * whoever reads the plan. What keeps a deleted line from destroying a pool is the resource's
78
- * `defaultRemovalPolicy: 'retain'` above — an orphaned pool is forgotten, not destroyed, and a
79
- * caller who means it says `.pipe(RemovalPolicy.destroy())`.
115
+ * ⛔ `DELETE /nodes/{node}/disks/zfs/{name}` RUNS `zpool destroy` AND THE DATASETS GO WITH IT. Per
116
+ * S14 (alchemy-provider-standard), it is implemented rather than stubbed — a `delete` that
117
+ * silently does nothing lies to whoever reads the plan — and what keeps an ordinary removed
118
+ * declaration from reaching it is the resource's `defaultRemovalPolicy: 'retain'` (zfs-pool.ts):
119
+ * an orphaned pool is forgotten, not destroyed, unless a caller opts in with
120
+ * `.pipe(RemovalPolicy.destroy())`. See the corrected header in zfs-pool.ts.
80
121
  *
81
122
  * ⚠️ `cleanup-config` IS NOT SENT. It would additionally remove the PVE storage entry pointing
82
123
  * at this pool, which is a different object with its own resource (`Proxmox.Storage`); one
@@ -1 +1 @@
1
- {"version":3,"file":"zfs-pool-write.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-write.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,EAAE,YAA6D,CAAC;AAE9F,eAAO,MAAM,UAAU,UAAW,YAAY,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAOpE,CAAC;AAKH;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,CAAC,SACtB,YAAY,cACP,MAAM,QACZ,MAAM,QACN,CAAC,KAAK,EAAE,YAAY,KAAK,MAAM,CAAC,MAAM,CAAC,iBAAiB,GAAG,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC,0GAmC/E,CAAC;AAEL;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,WAAW,SAAU,YAAY,QAAQ,MAAM,wFACb,CAAC"}
1
+ {"version":3,"file":"zfs-pool-write.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool-write.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,EAAE,YAA6D,CAAC;AAE9F;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,eAAe,UAAW,YAAY,KAAG,YACiC,CAAC;AAExF;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,UAAW,YAAY,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAOpE,CAAC;AAKH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,CAAC,SACtB,YAAY,cACP,MAAM,QACZ,MAAM,QACN,CAAC,KAAK,EAAE,YAAY,KAAK,MAAM,CAAC,MAAM,CAAC,iBAAiB,GAAG,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC,0GA8C/E,CAAC;AAEL;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,SAAU,YAAY,QAAQ,MAAM,wFACb,CAAC"}
@@ -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
@@ -56,10 +59,19 @@
56
59
  */
57
60
  import { Resource } from 'alchemy';
58
61
  import * as Provider from 'alchemy/Provider';
62
+ import type { NodesNodeDisksZfsPostParams } from './generated/pve.ts';
59
63
  import { type PveRequirements, type WithTarget } from './resource.ts';
60
- /** PVE's layouts. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an even one. */
61
- export type ZfsRaidLevel = 'single' | 'mirror' | 'raid10' | 'raidz' | 'raidz2' | 'raidz3' | 'draid' | 'draid2' | 'draid3';
62
- export type ZfsCompression = 'on' | 'off' | 'gzip' | 'lz4' | 'lzjb' | 'zle' | 'zstd';
64
+ /**
65
+ * PVE's layouts, generated (2026-09-23) from `NodesNodeDisksZfsPostParams['raidlevel']`
66
+ * (`generated/pve.ts`, manifest `pve-apidoc` pve-manager 9.2.11 sha256 9def8f13…) rather than
67
+ * hand-typed, per decision 9. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an
68
+ * even one. ⛔ THERE IS NO `stripe` IN THIS ENUM. n1's `speed` pool is one (decision 14 accepts it
69
+ * as-is): `ZfsPoolProps` below is why a stripe pool is declarable at all — as an ADOPT-ONLY
70
+ * declaration with no `raidlevel`, never as a full one.
71
+ */
72
+ export type ZfsRaidLevel = NonNullable<NodesNodeDisksZfsPostParams['raidlevel']>;
73
+ /** Generated the same way, from `NodesNodeDisksZfsPostParams['compression']`. */
74
+ export type ZfsCompression = NonNullable<NodesNodeDisksZfsPostParams['compression']>;
63
75
  /**
64
76
  * ⛔ THERE IS NO `add_storage`, `cleanup-config` OR `cleanup-disks` PROP, AND THAT IS NOT AN
65
77
  * OVERSIGHT. The last two belong to the DELETE this resource refuses to make. `add_storage=1`
@@ -91,9 +103,19 @@ export interface ZfsPoolProps extends WithTarget {
91
103
  * `csv` sorts, and sorting this list silently re-pairs the mirrors into a different pool.
92
104
  * ⚠️ PVE's only guard is `assert_disk_unused`, i.e. its own `disk_is_used` over the same `used`
93
105
  * column the Disks view shows. A blank disk it considers free is wiped with no confirmation.
106
+ *
107
+ * ⛔ OPTIONAL SINCE DECISION 9 (2026-09-23), AND OMITTING IT MEANS SOMETHING SPECIFIC: leaving
108
+ * both this and `raidlevel` undeclared declares an ADOPT-ONLY pool — one this resource may
109
+ * read and report but never build. That is the only shape available for a pool PVE's own POST
110
+ * schema cannot fully describe, such as n1's `speed`, whose layout is a stripe and whose
111
+ * `raidlevel` enum (above) has no stripe to declare. `createForm` (zfs-pool-write.ts) omits
112
+ * both keys when they are absent, and `createPool` refuses before any POST if the pool it
113
+ * would need to build is not already there — an adopt-only declaration never creates. Declare
114
+ * both fields together for a pool this resource should build from scratch; declaring only one
115
+ * of the two is refused the same way, since PVE's POST requires both.
94
116
  */
95
- devices: readonly string[];
96
- raidlevel: ZfsRaidLevel;
117
+ devices?: readonly string[];
118
+ raidlevel?: ZfsRaidLevel;
97
119
  /**
98
120
  * Sector size exponent, 9–16, PVE default 12.
99
121
  *
@@ -1 +1 @@
1
- {"version":3,"file":"zfs-pool.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,EACL,KAAK,eAAe,EAEpB,KAAK,UAAU,EAGhB,MAAM,eAAe,CAAC;AAIvB,oGAAoG;AACpG,MAAM,MAAM,YAAY,GACpB,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,OAAO,GACP,QAAQ,GACR,QAAQ,GACR,OAAO,GACP,QAAQ,GACR,QAAQ,CAAC;AAEb,MAAM,MAAM,cAAc,GAAG,IAAI,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;AAErF;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,+FAA+F;IAC/F,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;;OAYG;IACH,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,SAAS,EAAE,YAAY,CAAC;IACxB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,cAAc,CAAC;IAC7B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,mFAAmF;IACnF,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,YAAY,EACZ,iBAAiB,EACjB,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,mGAAmG;AACnG,eAAO,MAAM,cAAc,iDAEzB,CAAC;AA8EH,eAAO,MAAM,sBAAsB,oIACoD,CAAC"}
1
+ {"version":3,"file":"zfs-pool.d.ts","sourceRoot":"","sources":["../../src/proxmox/zfs-pool.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,oBAAoB,CAAC;AACtE,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,UAAU,EAA8B,MAAM,eAAe,CAAC;AAIlG;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG,WAAW,CAAC,2BAA2B,CAAC,WAAW,CAAC,CAAC,CAAC;AAEjF,iFAAiF;AACjF,MAAM,MAAM,cAAc,GAAG,WAAW,CAAC,2BAA2B,CAAC,aAAa,CAAC,CAAC,CAAC;AAErF;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,+FAA+F;IAC/F,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,SAAS,CAAC,EAAE,YAAY,CAAC;IACzB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,cAAc,CAAC;IAC7B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,mFAAmF;IACnF,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,YAAY,EACZ,iBAAiB,EACjB,KAAK,EACL,eAAe,CAChB;CAAG;AAEJ,mGAAmG;AACnG,eAAO,MAAM,cAAc,iDAEzB,CAAC;AAqBH,eAAO,MAAM,sBAAsB,oIACoD,CAAC"}
@@ -57,6 +57,8 @@ API token on the cluster, just as it does for `alchemy plan`.
57
57
  | `Proxmox.CephDaemon` ⁶ | `nodes/{node}/ceph/{mon,mgr,mds}` (whole list, by `name`) | read | shared path, and no `updateForm`: GET, GET — no write |
58
58
  | `Proxmox.CephFs` ⁶ | `nodes/{node}/ceph/fs` (whole list, by `name`) | read | own: GET, present so returned untouched — no write |
59
59
  | `Proxmox.CephOsd` ⁶ | `nodes/{node}/ceph/osd` (the CRUSH tree) | read | own: GET, host/class asserted; POSTs only with `dev` declared — no write |
60
+ | `Proxmox.ApiToken` ⁷ | `access/users/{userid}/token/{tokenid}` | provision ¹ | shared path — PUT skipped on `matches`; POST refused (`reconcile` dies rather than mint one) |
61
+ | `Proxmox.ZfsPool` ⁷ | `nodes/{node}/disks/zfs/{name}` | read | own: GET; no PUT exists on this family; POST only with `devices` AND `raidlevel` both declared |
60
62
 
61
63
  ¹ These object reads are gated on an allocate privilege, so the family reads with the provision
62
64
  lease (`readRole`, resource.ts). That is still a read.
@@ -83,6 +85,13 @@ declared flag reasserts a maintenance toggle on every deploy). Pinned by
83
85
  `src/proxmox/ceph-adopt.test.ts`, whose mutation check (retain flipped to destroy) shows nine
84
86
  DELETEs, one per mon, mgr and mds.
85
87
 
88
+ ⁷ Exported from the barrel 2026-09-23, decision 9. Both refuse to CREATE what they do not already
89
+ have: ApiToken's `reconcile` dies by name rather than mint a secret it cannot store (api-token.ts);
90
+ ZfsPool's `createPool` dies by name when `devices`/`raidlevel` are undeclared and the pool is not
91
+ already there (zfs-pool-write.ts) — the adopt-only shape a stripe pool such as n1's `speed` needs,
92
+ since PVE's own `raidlevel` enum has none. Pinned by `src/proxmox/api-token-adopt.test.ts` and
93
+ `src/proxmox/zfs-pool-adopt.test.ts`, whose mutation rows show one PUT and one DELETE respectively.
94
+
86
95
  ## Before the fix: `Proxmox.CephPool`
87
96
 
88
97
  `reconcile` read the pool and, whenever it existed, PUT the declared `size`, `min_size`,
@@ -103,6 +112,11 @@ list shows six such tasks (2026-09-13 and 2026-09-20), one per declared pool per
103
112
  - `src/proxmox/ceph-adopt.test.ts` adopts a reference cluster's nine daemons, one CephFS and six
104
113
  OSDs (GETs only), undeclares all sixteen (no DELETE), and refuses an OSD on the wrong host
105
114
  without a write.
115
+ - `src/proxmox/api-token-adopt.test.ts` adopts four token shapes read-only, refuses an absent
116
+ token, and shows a `privsep` flip PUTs exactly `comment`/`expire`/`privsep` once.
117
+ - `src/proxmox/zfs-pool-adopt.test.ts` adopts five adopt-only pools read-only, refuses an absent
118
+ or vanished one before any POST, and shows `RemovalPolicy.destroy()` then undeclaring sends
119
+ exactly one DELETE.
106
120
  - `src/verify/verify.test.ts` pins Alchemy's own behaviour: `adopted` for a match and for a
107
121
  drift, and a reconcile for both. If an Alchemy upgrade changes that, the test fails and this
108
122
  page needs re-reading.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `Proxmox.ApiToken` adopted, undeclared and drifted through Alchemy's own Plan and Apply, over a
3
+ * fake cluster — pinning the metadata-only shape decision 9 (2026-09-23) builds: adopt and manage
4
+ * `comment`/`expire`/`privsep`, never mint a token this resource cannot hand anybody the secret
5
+ * for (api-token.ts's header).
6
+ *
7
+ * ★ FOUR LIVE SHAPES: `metrics@pve!exporter` and `iac@pve!apply` are the no-comment / commented
8
+ * shapes named in api-token.ts's own header; `svc@pve!lease1` and `svc@pve!lease2` are
9
+ * PLACEHOLDERS (never an estate mint-user account — see the ⛔ on declaring under one in
10
+ * api-token.ts's header) exercising a real non-zero `expire` offered as BOTH a JSON number and
11
+ * a JSON string, which PVE does depending on path — `int()` (values.ts) must not care which.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import * as Effect from 'effect/Effect';
15
+ import * as Layer from 'effect/Layer';
16
+ import { engineOver } from '../verify/fake-engine.ts';
17
+ import { type ApiTokenProps, ProxmoxApiToken, ProxmoxApiTokenProvider } from './api-token.ts';
18
+ import { shape } from './api-token-form.ts';
19
+ import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
20
+ import type {
21
+ AccessUsersUseridTokenTokenidPostParams,
22
+ AccessUsersUseridTokenTokenidPutParams,
23
+ } from './generated/pve.ts';
24
+
25
+ type Live = { comment?: string; expire: number | string; privsep: number };
26
+
27
+ /** `access/users/{userid}/token/{tokenid}`, mutable so the PUT test can watch it change. */
28
+ const tokens = new Map<string, Live>([
29
+ ['access/users/metrics@pve/token/exporter', { expire: 0, privsep: 0 }],
30
+ ['access/users/iac@pve/token/apply', { comment: 'automation', expire: 0, privsep: 0 }],
31
+ ['access/users/svc@pve/token/lease1', { expire: 1789327175, privsep: 0 }],
32
+ ['access/users/svc@pve/token/lease2', { comment: 'lease', expire: '1789327175', privsep: 0 }],
33
+ ]);
34
+
35
+ const cluster = () =>
36
+ fakePve((call: PveCall) => {
37
+ if (call.method === 'GET') return tokens.get(call.path);
38
+ if (call.method === 'PUT') {
39
+ const live = tokens.get(call.path);
40
+ if (live !== undefined) tokens.set(call.path, { ...live, ...call.form });
41
+ return call.form;
42
+ }
43
+ return undefined;
44
+ });
45
+
46
+ /** The declared props matching a live fixture exactly — `expire` always as a `number` prop. */
47
+ const propsFor = (
48
+ userid: string,
49
+ tokenid: string,
50
+ over: { comment?: string; privsep?: boolean },
51
+ ): ApiTokenProps => ({
52
+ expire: Number(tokens.get(`access/users/${userid}/token/${tokenid}`)?.expire ?? 0),
53
+ privsep: over.privsep ?? false,
54
+ target: FAKE_TARGET,
55
+ tokenid,
56
+ userid,
57
+ ...(over.comment === undefined ? {} : { comment: over.comment }),
58
+ });
59
+
60
+ const token = (userid: string, tokenid: string, over: { comment?: string; privsep?: boolean }) =>
61
+ ProxmoxApiToken(`${userid.replace('@', '-')}-${tokenid}`, propsFor(userid, tokenid, over));
62
+
63
+ const engineFor = (fake: ReturnType<typeof cluster>) =>
64
+ engineOver(ProxmoxApiTokenProvider().pipe(Layer.provideMerge(fake.layer)));
65
+
66
+ const declared = () =>
67
+ Effect.all([
68
+ token('metrics@pve', 'exporter', {}),
69
+ token('iac@pve', 'apply', { comment: 'automation' }),
70
+ token('svc@pve', 'lease1', {}),
71
+ token('svc@pve', 'lease2', { comment: 'lease' }),
72
+ ]);
73
+
74
+ describe('adopting a metadata-only ApiToken declaration', () => {
75
+ test('no comment, commented, and a real expire as both a number and a string: noop, adopted, no writes', async () => {
76
+ const fake = cluster();
77
+ await withoutBao(async () => {
78
+ const engine = engineFor(fake);
79
+ const report = await engine.verify(declared());
80
+ expect(report.rows).toHaveLength(4);
81
+ for (const row of report.rows) {
82
+ expect(row).toMatchObject({ changed: [], diff: 'noop', ok: true, planned: 'adopted' });
83
+ }
84
+ const planned = await engine.deploy(declared());
85
+ expect(new Set(Object.values(planned))).toEqual(new Set(['adopted']));
86
+ expect(Object.keys(planned)).toHaveLength(4);
87
+ });
88
+ expect(fake.writes()).toEqual([]);
89
+ });
90
+
91
+ test('undeclaring all four sends no DELETE (retain is the default)', async () => {
92
+ const fake = cluster();
93
+ await withoutBao(async () => {
94
+ const engine = engineFor(fake);
95
+ await engine.deploy(declared());
96
+ expect(await engine.deploy(Effect.void)).toEqual({});
97
+ });
98
+ expect(fake.writes()).toEqual([]);
99
+ });
100
+
101
+ test('an absent token: the deploy refuses with the mint-elsewhere message, and writes nothing', async () => {
102
+ const fake = cluster();
103
+ const gone = () =>
104
+ ProxmoxApiToken('gone', {
105
+ expire: 0,
106
+ privsep: false,
107
+ target: FAKE_TARGET,
108
+ tokenid: 'missing',
109
+ userid: 'nope@pve',
110
+ });
111
+ await withoutBao(async () => {
112
+ const engine = engineFor(fake);
113
+ await expect(engine.deploy(gone())).rejects.toBeDefined();
114
+ });
115
+ expect(fake.writes()).toEqual([]);
116
+ });
117
+
118
+ test('a privsep flip: verify shows changed, and the deploy sends exactly one PUT of comment/expire/privsep', async () => {
119
+ const fake = cluster();
120
+ const flipped = () => token('iac@pve', 'apply', { comment: 'automation', privsep: true });
121
+ await withoutBao(async () => {
122
+ const engine = engineFor(fake);
123
+ const report = await engine.verify(flipped());
124
+ expect(report.rows[0]).toMatchObject({ changed: ['privsep'], diff: 'update', ok: false });
125
+ expect(await engine.deploy(flipped())).toEqual({ 'iac-pve-apply': 'adopted' });
126
+ });
127
+ const put = fake.calls.find((call) => call.method === 'PUT');
128
+ expect(fake.writes()).toEqual(['PUT access/users/iac@pve/token/apply']);
129
+ expect(put?.pairs.map(([name]) => name)).toEqual(['comment', 'expire', 'privsep']);
130
+ });
131
+
132
+ test('the form keys typecheck against both generated PVE params, with no cast', () => {
133
+ const props = propsFor('iac@pve', 'apply', { comment: 'automation' });
134
+ const post: AccessUsersUseridTokenTokenidPostParams = shape(props);
135
+ const put: AccessUsersUseridTokenTokenidPutParams = shape(props);
136
+ expect(post).toEqual(put);
137
+ });
138
+ });
@@ -16,6 +16,10 @@ import type { ApiTokenAttributes, ApiTokenProps } from './api-token.ts';
16
16
  * that costs a forever-diff: the schema gives `expire` the default "same as user", and the code
17
17
  * never implements it. api-token.ts records what that means.
18
18
  */
19
+ import type {
20
+ AccessUsersUseridTokenTokenidPostParams,
21
+ AccessUsersUseridTokenTokenidPutParams,
22
+ } from './generated/pve.ts';
19
23
  import type { PveSpec } from './resource.ts';
20
24
  import { bool, int, text } from './values.ts';
21
25
 
@@ -56,10 +60,23 @@ export const fullTokenid = (props: ApiTokenProps) => `${props.userid}!${props.to
56
60
  * required prop precisely so that it is never undeclared, so there is nothing for it to do and
57
61
  * its `string | undefined` return would have to be asserted away to fit `Record<string,string>`.
58
62
  * An assertion here would be a claim about the prop that the type already makes properly.
63
+ *
64
+ * ★ TYPED AGAINST THE GENERATED PUT AND POST PARAMS, NOT `Record<string, string>`, since
65
+ * 2026-09-23 — the `notification-matcher-form.ts` pattern: `Pick` rather than the whole type,
66
+ * because neither `AccessUsersUseridTokenTokenidPutParams` nor …`PostParams` (generated/pve.ts)
67
+ * carries `userid`/`tokenid` (they are path segments — see the ⛔ above) and Put alone also
68
+ * carries `delete`/`regenerate`, which this family never sends. A schema drift on any of the
69
+ * three fields this function DOES send fails `tsc` here rather than surfacing as a 400 on a live
70
+ * cluster; api-token.test.ts pins the assignment both ways (Put and Post) without a cast.
71
+ * `expire` is written as a template literal, not `String(...)`, because the generated type is
72
+ * `` `${number}` ``, not `string` — `String(n)` widens to plain `string` and would not typecheck.
59
73
  */
60
- export const shape = (props: ApiTokenProps): Record<string, string> => ({
74
+ export const shape = (
75
+ props: ApiTokenProps,
76
+ ): Pick<AccessUsersUseridTokenTokenidPutParams, 'comment' | 'expire' | 'privsep'> &
77
+ Pick<AccessUsersUseridTokenTokenidPostParams, 'comment' | 'expire' | 'privsep'> => ({
61
78
  comment: props.comment ?? '',
62
- expire: String(props.expire),
79
+ expire: `${props.expire}`,
63
80
  privsep: props.privsep ? '1' : '0',
64
81
  });
65
82
 
@@ -20,9 +20,14 @@
20
20
  * whole of a token's policy, and declaring them is real work: it is how `expire` stops being
21
21
  * whatever somebody typed in 2024. Mint NEW tokens where the secret can be caught — OpenBao's
22
22
  * `proxmox-c1` mount, or a human at `pveum user token add`.
23
- * ⚠️ THE CREATE PATH IS LEFT REACHABLE RATHER THAN STUBBED. A create that silently did nothing
24
- * would be the same lie as the stubbed delete resource.ts's ★ refuses; the honest arrangement
25
- * is a create that works and a header that says what it produces.
23
+ * ⛔ ★ CORRECTED 2026-09-23 — THIS PARAGRAPH PREVIOUSLY SAID "THE CREATE PATH IS LEFT REACHABLE
24
+ * RATHER THAN STUBBED… the honest arrangement is a create that works". THAT CONTRADICTED THE
25
+ * `reconcile` BELOW, WHICH REFUSES: `handlers.reconcile` calls `Effect.die` by name when `read`
26
+ * answers absent, before any POST — metadata-only per decision 9 (2026-09-23), exported on the
27
+ * barrel as an adopt-and-manage-only Resource. A create that silently did nothing WOULD be the
28
+ * lie resource.ts's ★ describes for a stubbed delete; refusing loudly, by name, with the two
29
+ * ways to get a usable token, is the honest arrangement instead — see the ★ further down and
30
+ * api-token-adopt.test.ts's absent-token row, which pins the refusal and zero writes.
26
31
  *
27
32
  * ⛔ THERE IS NO `regenerate` PROP, AND IT IS NOT AN OVERSIGHT. PVE's own description: "All users
28
33
  * of the previous secret will lose access after this operation." A prop for it would revoke a
@@ -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
  });