@homeflare/alchemy 0.31.2 → 0.33.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 +39 -3
- package/dist/grafana/dashboard-model.d.ts +77 -0
- package/dist/grafana/dashboard-model.d.ts.map +1 -0
- package/dist/grafana/dashboard.d.ts +98 -0
- package/dist/grafana/dashboard.d.ts.map +1 -0
- package/dist/grafana/folder.d.ts +100 -0
- package/dist/grafana/folder.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +9 -8
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +158 -6
- package/dist/grafana/index.js.map +8 -4
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/provisioned.d.ts +11 -0
- package/dist/grafana/provisioned.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +129 -0
- package/dist/linux/container-form.d.ts.map +1 -0
- package/dist/linux/container-generator.d.ts +74 -0
- package/dist/linux/container-generator.d.ts.map +1 -0
- package/dist/linux/container-handlers.d.ts +15 -0
- package/dist/linux/container-handlers.d.ts.map +1 -0
- package/dist/linux/container-lifecycle.d.ts +37 -0
- package/dist/linux/container-lifecycle.d.ts.map +1 -0
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -0
- package/dist/linux/container-secrets.d.ts +37 -0
- package/dist/linux/container-secrets.d.ts.map +1 -0
- package/dist/linux/container-settle.d.ts +34 -0
- package/dist/linux/container-settle.d.ts.map +1 -0
- package/dist/linux/container.d.ts +30 -0
- package/dist/linux/container.d.ts.map +1 -0
- package/dist/linux/fake-quadlet.d.ts +25 -0
- package/dist/linux/fake-quadlet.d.ts.map +1 -0
- package/dist/linux/index.d.ts +6 -3
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +641 -268
- package/dist/linux/index.js.map +15 -7
- package/dist/linux/providers.d.ts +1 -1
- package/dist/linux/providers.d.ts.map +1 -1
- package/dist/linux/systemctl.d.ts +7 -1
- package/dist/linux/systemctl.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +29 -0
- package/dist/opnsense/alias-form.d.ts.map +1 -0
- package/dist/opnsense/alias.d.ts +81 -0
- package/dist/opnsense/alias.d.ts.map +1 -0
- package/dist/opnsense/category-form.d.ts +10 -0
- package/dist/opnsense/category-form.d.ts.map +1 -0
- package/dist/opnsense/category.d.ts +58 -0
- package/dist/opnsense/category.d.ts.map +1 -0
- package/dist/opnsense/credentials.d.ts +36 -0
- package/dist/opnsense/credentials.d.ts.map +1 -0
- package/dist/opnsense/fake-opnsense.d.ts +51 -0
- package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
- package/dist/opnsense/group-form.d.ts +11 -0
- package/dist/opnsense/group-form.d.ts.map +1 -0
- package/dist/opnsense/group.d.ts +62 -0
- package/dist/opnsense/group.d.ts.map +1 -0
- package/dist/opnsense/index.d.ts +16 -0
- package/dist/opnsense/index.d.ts.map +1 -0
- package/dist/opnsense/index.js +211 -0
- package/dist/opnsense/index.js.map +20 -0
- package/dist/opnsense/policy.d.ts +20 -0
- package/dist/opnsense/policy.d.ts.map +1 -0
- package/dist/opnsense/providers.d.ts +13 -0
- package/dist/opnsense/providers.d.ts.map +1 -0
- package/dist/opnsense/resource.d.ts +74 -0
- package/dist/opnsense/resource.d.ts.map +1 -0
- package/dist/opnsense/wire.d.ts +39 -0
- package/dist/opnsense/wire.d.ts.map +1 -0
- package/dist/proxmox/index.js +170 -80
- package/dist/proxmox/index.js.map +9 -7
- package/dist/proxmox/storage-form.d.ts +54 -0
- package/dist/proxmox/storage-form.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +56 -0
- package/dist/proxmox/storage-wire.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +55 -60
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-form.d.ts +124 -0
- package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
- package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool.d.ts +27 -52
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +48 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -0
- package/dist/unifi/firewall-zone-form.d.ts +40 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
- package/dist/unifi/firewall-zone.d.ts +50 -0
- package/dist/unifi/firewall-zone.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +13 -0
- package/dist/unifi/index.d.ts.map +1 -0
- package/dist/unifi/index.js +190 -0
- package/dist/unifi/index.js.map +16 -0
- package/dist/unifi/network-form.d.ts +67 -0
- package/dist/unifi/network-form.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +67 -0
- package/dist/unifi/network.d.ts.map +1 -0
- package/dist/unifi/policy.d.ts +20 -0
- package/dist/unifi/policy.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +13 -0
- package/dist/unifi/providers.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +64 -0
- package/dist/unifi/resource.d.ts.map +1 -0
- package/docs/grafana-folder-dashboard.md +145 -0
- package/docs/grafana.md +11 -10
- package/docs/opnsense.md +146 -0
- package/docs/provider-standard.md +1 -1
- package/docs/quadlet-container.md +132 -0
- package/docs/unifi.md +93 -0
- package/docs/upstream-conformance.md +9 -4
- package/package.json +12 -2
- package/src/grafana/dashboard-model.test.ts +111 -0
- package/src/grafana/dashboard-model.ts +111 -0
- package/src/grafana/dashboard.test.ts +232 -0
- package/src/grafana/dashboard.ts +161 -0
- package/src/grafana/folder.test.ts +212 -0
- package/src/grafana/folder.ts +161 -0
- package/src/grafana/index.ts +21 -8
- package/src/grafana/providers.ts +7 -1
- package/src/grafana/provisioned.ts +49 -0
- package/src/linux/container-fixture.test.ts +92 -0
- package/src/linux/container-form.ts +243 -0
- package/src/linux/container-generator.test.ts +195 -0
- package/src/linux/container-generator.ts +113 -0
- package/src/linux/container-handlers.ts +73 -0
- package/src/linux/container-lifecycle.test.ts +209 -0
- package/src/linux/container-lifecycle.ts +217 -0
- package/src/linux/container-preflight.ts +97 -0
- package/src/linux/container-secrets.test.ts +171 -0
- package/src/linux/container-secrets.ts +112 -0
- package/src/linux/container-settle.ts +97 -0
- package/src/linux/container.ts +60 -0
- package/src/linux/fake-quadlet.ts +127 -0
- package/src/linux/index.ts +11 -3
- package/src/linux/providers.ts +2 -0
- package/src/linux/systemctl.ts +9 -0
- package/src/opnsense/alias-form.test.ts +89 -0
- package/src/opnsense/alias-form.ts +53 -0
- package/src/opnsense/alias.test.ts +203 -0
- package/src/opnsense/alias.ts +117 -0
- package/src/opnsense/category-form.test.ts +54 -0
- package/src/opnsense/category-form.ts +20 -0
- package/src/opnsense/category.test.ts +138 -0
- package/src/opnsense/category.ts +82 -0
- package/src/opnsense/credentials.ts +41 -0
- package/src/opnsense/fake-opnsense.ts +79 -0
- package/src/opnsense/group-form.test.ts +75 -0
- package/src/opnsense/group-form.ts +25 -0
- package/src/opnsense/group.test.ts +141 -0
- package/src/opnsense/group.ts +78 -0
- package/src/opnsense/index.ts +45 -0
- package/src/opnsense/policy.ts +49 -0
- package/src/opnsense/providers.ts +21 -0
- package/src/opnsense/resource.ts +146 -0
- package/src/opnsense/wire.ts +56 -0
- package/src/opnsense/write-refusal.test.ts +148 -0
- package/src/proxmox/constraints-host-forms.test.ts +1 -1
- package/src/proxmox/storage-form.ts +144 -0
- package/src/proxmox/storage-read-failure.test.ts +214 -0
- package/src/proxmox/storage-wire.ts +124 -0
- package/src/proxmox/storage.ts +165 -175
- package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
- package/src/proxmox/zfs-pool-form.ts +236 -0
- package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
- package/src/proxmox/zfs-pool-wire.ts +132 -0
- package/src/proxmox/zfs-pool.ts +106 -76
- package/src/unifi/fake-unifi.ts +60 -0
- package/src/unifi/firewall-zone-form.ts +65 -0
- package/src/unifi/firewall-zone.test.ts +88 -0
- package/src/unifi/firewall-zone.ts +66 -0
- package/src/unifi/index.ts +26 -0
- package/src/unifi/network-form.ts +207 -0
- package/src/unifi/network.test.ts +209 -0
- package/src/unifi/network.ts +83 -0
- package/src/unifi/policy.ts +37 -0
- package/src/unifi/providers.ts +15 -0
- package/src/unifi/resource.test.ts +133 -0
- package/src/unifi/resource.ts +131 -0
- package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
- package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
- package/dist/proxmox/zfs-pool-write.d.ts +0 -128
- package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
- package/src/proxmox/zfs-pool-spec.ts +0 -76
- package/src/proxmox/zfs-pool-write.ts +0 -196
package/src/proxmox/storage.ts
CHANGED
|
@@ -12,19 +12,48 @@
|
|
|
12
12
|
* ⛔ ONE PRIVILEGE COVERS THIS WHOLE FAMILY AND IT IS `Datastore.Allocate` ON `/storage`. Create,
|
|
13
13
|
* update, delete AND the single-object read all check it — `Datastore.Audit` is not enough for
|
|
14
14
|
* any of them. MEASURED: `GET /storage/local` under the auditor-scoped `read` lease answers 403
|
|
15
|
-
* "Permission check failed (/storage/local, Datastore.Allocate)".
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
15
|
+
* "Permission check failed (/storage/local, Datastore.Allocate)". So this family reads with
|
|
16
|
+
* `provision`, same as acl.ts and unlike group.ts/user.ts — `provision-baseline.ts` carries
|
|
17
|
+
* `Datastore.Allocate`.
|
|
18
|
+
*
|
|
19
|
+
* ⛔ ON A LANE WHOSE CREDENTIAL CANNOT MINT `provision` (the agent's `read`-role lane), THIS
|
|
20
|
+
* FAMILY SHIPPED WITH THE ORIGINAL CRIES-WOLF BUG — measured live, 2026-09-24: `bun run plan`
|
|
21
|
+
* showed all 5 storages (`cephfs-tb4`, `cephtb4`, `local`, `local-zfs`, `pbs`) as `update`, with
|
|
22
|
+
* no warning, because `client.ts`'s `pveOperations.read` folded the REFUSED mint into `undefined`
|
|
23
|
+
* the same way it folds a genuine absence — `unreadable-read.ts`'s own header names this family
|
|
24
|
+
* by name as one of the two the fix was written for. Migrated here the way acl.ts/group.ts/
|
|
25
|
+
* user.ts were (kit 0.31.1/0.31.2): a refused `provision` mint now reports `noop` with a logged
|
|
26
|
+
* warning instead of forcing `update` with nothing compared.
|
|
27
|
+
*
|
|
28
|
+
* ⛔ A MISSING STORAGE IS A 500, NOT A 404 — MEASURED against the live cluster (TB4, 2026-09-24,
|
|
29
|
+
* admin lane, read-only `GET /storage/hf-measure-nonexistent-probe`): `{"message":"storage
|
|
30
|
+
* '...' does not exist\n"}` at HTTP 500. `storage-wire.ts`'s `readStorageOrFail`/`readStorage`
|
|
31
|
+
* carry the same dual-path split as user.ts/group.ts for exactly this reason — see that file.
|
|
32
|
+
*
|
|
33
|
+
* ⛔ `maxfiles` IS GONE — A MEASURED `@distilled.cloud/proxmox` SDK GAP, NOT A SCOPE TRIM. The
|
|
34
|
+
* vendor's generated `CreateStorageRequest`/`PutStorageRequest` (storage-wire.ts's `mutable`)
|
|
35
|
+
* have no `maxfiles` field at all — grepped across the whole package, not just this endpoint —
|
|
36
|
+
* where `prune-backups`, its documented replacement, is present and correctly wired
|
|
37
|
+
* (`T.Body("prune-backups")` on the generated `prune_backups` property). Silently dropping a
|
|
38
|
+
* declared `maxfiles` value would have been the same class of bug this whole migration exists to
|
|
39
|
+
* close: a write that looks like it landed and did not. So `maxfiles?: never` below, the same
|
|
40
|
+
* COMPILE-ERROR treatment `StorageLocator`'s `password`/`keyring`/`encryption-key` already get —
|
|
41
|
+
* a declaration that sets it is refused before a plan ever runs, not silently ignored by one.
|
|
42
|
+
* None of this cluster's 5 live storages set it (measured, live response bodies below). If a
|
|
43
|
+
* consumer genuinely needs it, the fix belongs in `@distilled.cloud/proxmox`'s generator, not a
|
|
44
|
+
* hand-rolled bypass here — see the provider-walk-down doctrine on vendor gaps.
|
|
45
|
+
*
|
|
46
|
+
* ⛔ EVERY HYPHENATED PVE FIELD NAME BECOMES AN UNDERSCORE IN DISTILLED'S GENERATED TYPESCRIPT —
|
|
47
|
+
* MEASURED across the WHOLE generated `CreateStorageRequest`/`PutStorageRequest`, not one field:
|
|
48
|
+
* `prune-backups` -> `prune_backups`, `fs-name` -> `fs_name`, `max-protected-backups` ->
|
|
49
|
+
* `max_protected_backups`, and so on for every plugin field `StorageLocator`'s free-form bag can
|
|
50
|
+
* carry (`cephfs-tb4`'s own live locator uses `fs-name`, measured below). No other family
|
|
51
|
+
* migrated so far has a hyphenated field at all, so this is new here. `storage-wire.ts`'s
|
|
52
|
+
* `underscored` translates a form BUILT with PVE's own names into distilled's shape, but ONLY
|
|
53
|
+
* for the actual SDK call (`toDistilledCreate`/`toDistilledUpdate`) — never for the form
|
|
54
|
+
* `guardWrite` checks (`createForm`/`updateForm`), which the generated vendor-constraint tables
|
|
55
|
+
* key by PVE's own hyphenated names. Running the translation before that check would make it
|
|
56
|
+
* silently stop inspecting the very fields it exists to catch.
|
|
28
57
|
*
|
|
29
58
|
* ⛔ THERE IS NO `password` PROP AND THERE MUST NEVER BE ONE. PBS and CIFS storages take one at
|
|
30
59
|
* create time, and Alchemy persists attributes UNENCRYPTED — a secret in props is one careless
|
|
@@ -33,28 +62,36 @@
|
|
|
33
62
|
* a COMPILE ERROR rather than a leak somebody finds in a backup months later. Set it once out of
|
|
34
63
|
* band (`pvesm set <id> --password`); PVE never returns it on read, so nothing here could diff it
|
|
35
64
|
* anyway.
|
|
65
|
+
*
|
|
66
|
+
* ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveHandlers` (`resource.ts`) ONTO
|
|
67
|
+
* `@distilled.cloud/proxmox`'s typed `storage.getStorage`/`createStorage`/`putStorage`/
|
|
68
|
+
* `deleteStorage` (2026-09-24, decision 43's proxmox walk-down, nodes/storage sub-area, first
|
|
69
|
+
* resource). `distilled-pve.ts`'s `runPve` replaces `pve()`; the cries-wolf fix is wired the
|
|
70
|
+
* same way as acl.ts/group.ts/user.ts.
|
|
36
71
|
*/
|
|
37
72
|
import { Resource } from 'alchemy';
|
|
73
|
+
import { isResolved } from 'alchemy/Diff';
|
|
38
74
|
import * as Provider from 'alchemy/Provider';
|
|
75
|
+
import * as storage from '@distilled.cloud/proxmox/storage';
|
|
39
76
|
import * as Effect from 'effect/Effect';
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
77
|
+
import { guardWrite } from './distilled-guard.ts';
|
|
78
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
79
|
+
import { runPve } from './distilled-pve.ts';
|
|
80
|
+
import {
|
|
81
|
+
STORAGE_CREATE,
|
|
82
|
+
STORAGE_UPDATE,
|
|
83
|
+
createForm,
|
|
84
|
+
toDistilledCreate,
|
|
85
|
+
toDistilledUpdate,
|
|
86
|
+
updateForm,
|
|
87
|
+
} from './storage-form.ts';
|
|
88
|
+
import { dropUnreadable, matches, readStorage, readStorageOrFail } from './storage-wire.ts';
|
|
89
|
+
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
90
|
+
import type { StorageAttributes } from './storage-wire.ts';
|
|
91
|
+
import type { StorageLocator } from './storage-form.ts';
|
|
42
92
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
* `cifs` `server`+`share`, `pbs` `server`+`datastore`, `rbd` `pool`+`monhost`, `lvmthin`
|
|
46
|
-
* `vgname`+`thinpool`, `zfspool` `pool`. A bag rather than two dozen named fields, because PVE
|
|
47
|
-
* ships about that many plugins and each brings its own locator. ⚠️ Never diffed — see `matches`.
|
|
48
|
-
*
|
|
49
|
-
* ⛔ `password`, `keyring` AND `encryption-key` ARE `never` ON PURPOSE — see the ⛔ in the header.
|
|
50
|
-
* POSITIVE-CONTROLLED rather than assumed: all three are a compile error, `TS2322: Type 'string'
|
|
51
|
-
* is not assignable to type 'undefined'`.
|
|
52
|
-
*/
|
|
53
|
-
export type StorageLocator = Record<string, string> & {
|
|
54
|
-
password?: never;
|
|
55
|
-
keyring?: never;
|
|
56
|
-
'encryption-key'?: never;
|
|
57
|
-
};
|
|
93
|
+
/** Re-exported so a caller of this file still finds them here, unchanged by the split below. */
|
|
94
|
+
export type { StorageAttributes, StorageLocator };
|
|
58
95
|
|
|
59
96
|
export interface StorageProps extends WithTarget {
|
|
60
97
|
/** PVE's primary key, cluster-wide — the string an LXC's `storage` and `ostemplate` name. */
|
|
@@ -85,26 +122,8 @@ export interface StorageProps extends WithTarget {
|
|
|
85
122
|
*/
|
|
86
123
|
'prune-backups'?: string;
|
|
87
124
|
comment?: string;
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
* other. `0` means UNLIMITED to PVE rather than "unset", hence the `-1` in the attribute.
|
|
91
|
-
*/
|
|
92
|
-
maxfiles?: number;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface StorageAttributes {
|
|
96
|
-
storage: string;
|
|
97
|
-
/** Reported so a plan can show what it points at; never diffed — see `type` above. */
|
|
98
|
-
type: string;
|
|
99
|
-
content: string;
|
|
100
|
-
nodes: string;
|
|
101
|
-
disable: boolean;
|
|
102
|
-
shared: boolean;
|
|
103
|
-
preallocation: string;
|
|
104
|
-
'prune-backups': string;
|
|
105
|
-
comment: string;
|
|
106
|
-
/** ⚠️ `-1` means "no maxfiles in storage.cfg". `0` is a real setting meaning unlimited. */
|
|
107
|
-
maxfiles: number;
|
|
125
|
+
/** ⛔ REMOVED — a measured `@distilled.cloud/proxmox` gap, not a scope trim. See the header. */
|
|
126
|
+
maxfiles?: never;
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
export interface ProxmoxStorage extends Resource<
|
|
@@ -120,129 +139,100 @@ export const ProxmoxStorage = Resource<ProxmoxStorage>('Proxmox.Storage', {
|
|
|
120
139
|
defaultRemovalPolicy: 'retain',
|
|
121
140
|
});
|
|
122
141
|
|
|
123
|
-
const str = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback);
|
|
124
|
-
|
|
125
|
-
const field = (name: string, value: string | undefined): Record<string, string> =>
|
|
126
|
-
value === undefined ? {} : { [name]: value };
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* ⚠️ THE CAST DROPS THE THREE `never` GUARDS AND NOTHING ELSE, and it is load-bearing rather than
|
|
130
|
-
* lazy: an intersection carrying OPTIONAL properties is not assignable to `Record<string,string>`
|
|
131
|
-
* at all, so the guards and the form body cannot both exist without one conversion. At runtime a
|
|
132
|
-
* locator is a plain string map; the guards only ever existed to fail a declaration.
|
|
133
|
-
*/
|
|
134
|
-
const locatorForm = (locator: StorageLocator | undefined) =>
|
|
135
|
-
({ ...locator }) as Record<string, string>;
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* ⚠️ PVE RE-SERIALISES ITS LIST FIELDS IN ITS OWN ORDER — `content` and `nodes` are parsed into a
|
|
139
|
-
* set and written back sorted, `prune-backups` is a property string that comes back in schema
|
|
140
|
-
* order. Comparing raw strings would report an update for `iso,backup` against `backup,iso` on
|
|
141
|
-
* every plan, and the PUT would "fix" it into the identical string. Trimming matters too: a
|
|
142
|
-
* declared `iso, backup` would otherwise never equal what PVE stores.
|
|
143
|
-
*/
|
|
144
|
-
const tokens = (value: string) =>
|
|
145
|
-
value
|
|
146
|
-
.split(',')
|
|
147
|
-
.map((part) => part.trim())
|
|
148
|
-
.filter((part) => part !== '')
|
|
149
|
-
.sort()
|
|
150
|
-
.join(',');
|
|
151
|
-
|
|
152
|
-
const sameList = (declared: string | undefined, live: string) =>
|
|
153
|
-
declared === undefined || tokens(declared) === tokens(live);
|
|
154
|
-
|
|
155
|
-
const same = <T>(declared: T | undefined, live: T) => declared === undefined || declared === live;
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* The mutable half, for create and update alike.
|
|
159
|
-
*
|
|
160
|
-
* ⚠️ AN UNDECLARED FIELD IS NEITHER SENT NOR COMPARED: undeclared means UNMANAGED here, unlike
|
|
161
|
-
* `Proxmox.Pool` where an absent comment means `''`. A storage has no single default to fall back
|
|
162
|
-
* on — `content` defaults per plugin, and clearing a field needs an explicit `delete=` parameter
|
|
163
|
-
* rather than an empty value — so a guessed default would rewrite a storage somebody tuned by
|
|
164
|
-
* hand, and comparing one would report drift nobody declared.
|
|
165
|
-
*
|
|
166
|
-
* ⚠️ RECONCILE PUTs UNCONDITIONALLY whenever the object exists (resource.ts), so this form must be
|
|
167
|
-
* safe to re-apply to a storage that already matches. It is: the declared set and nothing else,
|
|
168
|
-
* and PVE leaves absent parameters alone.
|
|
169
|
-
*/
|
|
170
|
-
const mutable = (props: StorageProps): Record<string, string> => ({
|
|
171
|
-
...field('comment', props.comment),
|
|
172
|
-
...field('content', props.content),
|
|
173
|
-
...field('disable', flag(props.disable)),
|
|
174
|
-
...field('maxfiles', props.maxfiles === undefined ? undefined : String(props.maxfiles)),
|
|
175
|
-
...field('nodes', props.nodes),
|
|
176
|
-
...field('preallocation', props.preallocation),
|
|
177
|
-
...field('prune-backups', props['prune-backups']),
|
|
178
|
-
...field('shared', flag(props.shared)),
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
const handlers = pveHandlers<StorageProps, StorageAttributes>({
|
|
182
|
-
// ⛔ PVE gates this family's ITEM read on the allocate privilege, not the audit one — see
|
|
183
|
-
// `readRole` in resource.ts. The auditor-shaped lease reads 403, which `read` turns into
|
|
184
|
-
// "absent", and the plan then says create for an object that is plainly there.
|
|
185
|
-
readRole: 'provision',
|
|
186
|
-
/**
|
|
187
|
-
* ⚠️ `digest` IS DELIBERATELY NOT AN ATTRIBUTE. PVE returns one on every storage read and it
|
|
188
|
-
* covers storage.cfg as a FILE, not this section of it, so keeping it would rewrite this
|
|
189
|
-
* resource's state whenever an unrelated storage was edited — churn that reads like drift.
|
|
190
|
-
*/
|
|
191
|
-
attributes: (live, props) => ({
|
|
192
|
-
comment: str(live['comment']),
|
|
193
|
-
content: str(live['content']),
|
|
194
|
-
disable: bool(live['disable']),
|
|
195
|
-
maxfiles: typeof live['maxfiles'] === 'number' ? live['maxfiles'] : -1,
|
|
196
|
-
nodes: str(live['nodes']),
|
|
197
|
-
preallocation: str(live['preallocation']),
|
|
198
|
-
'prune-backups': str(live['prune-backups']),
|
|
199
|
-
shared: bool(live['shared']),
|
|
200
|
-
storage: props.storage,
|
|
201
|
-
type: str(live['type'], props.type),
|
|
202
|
-
}),
|
|
203
|
-
collection: () => 'storage',
|
|
204
|
-
/**
|
|
205
|
-
* ⚠️ THE BAG IS SPREAD FIRST SO NOTHING IN IT CAN SHADOW A FIELD THIS RESOURCE MANAGES. A stray
|
|
206
|
-
* `storage` or `type` in a locator would otherwise rename the object being created, and PVE
|
|
207
|
-
* would build the wrong thing under a name Alchemy then records as the declared one.
|
|
208
|
-
*/
|
|
209
|
-
createForm: (props) => ({
|
|
210
|
-
...locatorForm(props.locator),
|
|
211
|
-
...mutable(props),
|
|
212
|
-
storage: props.storage,
|
|
213
|
-
type: props.type,
|
|
214
|
-
}),
|
|
215
|
-
/**
|
|
216
|
-
* ⚠️ `type` AND THE LOCATOR ARE NEVER COMPARED, AND AN UNDECLARED FIELD IS NOT COMPARED EITHER.
|
|
217
|
-
* PVE returns the create-only fields on read but refuses them on PUT, so diffing one could only
|
|
218
|
-
* plan an update that no write can apply: a plan that reports work on every run, forever.
|
|
219
|
-
*/
|
|
220
|
-
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
221
|
-
endpoint: { create: 'pve:POST /storage', update: 'pve:PUT /storage/{storage}' },
|
|
222
|
-
matches: (attributes, props) =>
|
|
223
|
-
sameList(props.content, attributes.content) &&
|
|
224
|
-
sameList(props.nodes, attributes.nodes) &&
|
|
225
|
-
sameList(props['prune-backups'], attributes['prune-backups']) &&
|
|
226
|
-
same(props.disable, attributes.disable) &&
|
|
227
|
-
same(props.shared, attributes.shared) &&
|
|
228
|
-
same(props.preallocation, attributes.preallocation) &&
|
|
229
|
-
same(props.comment, attributes.comment) &&
|
|
230
|
-
same(props.maxfiles, attributes.maxfiles),
|
|
231
|
-
path: (props) => `storage/${props.storage}`,
|
|
232
|
-
updateForm: mutable,
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* ⛔ EMPTY, AND HERE IT MATTERS MORE THAN ANYWHERE ELSE IN THIS PACKAGE. `GET /storage`
|
|
237
|
-
* returns every definition on the cluster, `local` and `local-lvm`/`local-zfs` included:
|
|
238
|
-
* the installer's own, that every guest's rootfs sits on. Adopting those would put
|
|
239
|
-
* Alchemy one `delete` away from the cluster's disks. Adoption is explicit, always.
|
|
240
|
-
|
|
241
|
-
*
|
|
242
|
-
* ⚠️ THIS REMOVES THE DEFINITION, NOT THE DATA. PVE drops the section from storage.cfg and
|
|
243
|
-
* leaves the volumes on disk untouched — so the loss is not the bytes, it is that guests
|
|
244
|
-
* whose config still names this storage can no longer resolve their own disks.
|
|
245
|
-
|
|
246
|
-
*/
|
|
247
142
|
export const ProxmoxStorageProvider = () =>
|
|
248
|
-
Provider.effect(
|
|
143
|
+
Provider.effect(
|
|
144
|
+
ProxmoxStorage,
|
|
145
|
+
Effect.succeed(
|
|
146
|
+
ProxmoxStorage.Provider.of({
|
|
147
|
+
/**
|
|
148
|
+
* ⛔ EMPTY, AND HERE IT MATTERS MORE THAN ANYWHERE ELSE IN THIS PACKAGE. `GET /storage`
|
|
149
|
+
* returns every definition on the cluster, `local` and `local-lvm`/`local-zfs` included:
|
|
150
|
+
* the installer's own, that every guest's rootfs sits on. Adopting those would put
|
|
151
|
+
* Alchemy one `delete` away from the cluster's disks. Adoption is explicit, always.
|
|
152
|
+
*/
|
|
153
|
+
list: () => Effect.succeed([]),
|
|
154
|
+
// ⚠️ FOLDS ONLY WHEN `output` IS `undefined` — user.ts's/group.ts's own ⚠️, applied here
|
|
155
|
+
// from the start rather than found by a later review: the engine calls this hook from
|
|
156
|
+
// Plan.ts's adoption probe and interrupted-create recovery and Apply.ts's delete
|
|
157
|
+
// recovery (all `output: undefined`, nothing confirmed exists yet — `readStorage`'s fold
|
|
158
|
+
// is still needed) as well as Drift.ts (`output: old.attr`, an ALREADY-CONFIRMED row,
|
|
159
|
+
// `diff`'s own situation — `readStorageOrFail` there instead, or a transient failure
|
|
160
|
+
// reports a silent `{action: 'missing'}` with no error at all).
|
|
161
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
162
|
+
return dropUnreadable(
|
|
163
|
+
yield* output === undefined ? readStorage(olds) : readStorageOrFail(olds),
|
|
164
|
+
);
|
|
165
|
+
}),
|
|
166
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
167
|
+
if (!isResolved(news)) return undefined;
|
|
168
|
+
yield* guardWrite(STORAGE_CREATE, createForm(news), output === undefined);
|
|
169
|
+
yield* guardWrite(STORAGE_UPDATE, updateForm(news), false);
|
|
170
|
+
if (output === undefined) return undefined;
|
|
171
|
+
// ⚠️ `readStorageOrFail`, NOT `readStorage` — see that function's own ⛔ in
|
|
172
|
+
// storage-wire.ts. A genuine TRANSIENT failure here propagates and fails the whole
|
|
173
|
+
// plan loudly instead of folding to "absent" and forcing a false update.
|
|
174
|
+
const live = yield* readStorageOrFail(news);
|
|
175
|
+
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
176
|
+
// `update` on a storage that was plainly there — see unreadable-read.ts.
|
|
177
|
+
if (live === UNREADABLE) {
|
|
178
|
+
yield* unreadableWarning('Proxmox.Storage', news.storage);
|
|
179
|
+
return { action: 'noop' } as const;
|
|
180
|
+
}
|
|
181
|
+
if (live === undefined) {
|
|
182
|
+
yield* guardWrite(STORAGE_CREATE, createForm(news), true);
|
|
183
|
+
return { action: 'update' } as const;
|
|
184
|
+
}
|
|
185
|
+
return matches(live, news)
|
|
186
|
+
? ({ action: 'noop' } as const)
|
|
187
|
+
: ({ action: 'update' } as const);
|
|
188
|
+
}),
|
|
189
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
190
|
+
// ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
|
|
191
|
+
// write below, so `UNREADABLE` here is a narrow race, not the routine case `diff`
|
|
192
|
+
// handles — group.ts's/user.ts's reconcile have the same note.
|
|
193
|
+
const before = dropUnreadable(yield* readStorage(news));
|
|
194
|
+
yield* guardWrite(STORAGE_CREATE, createForm(news), before === undefined);
|
|
195
|
+
yield* guardWrite(STORAGE_UPDATE, updateForm(news), false);
|
|
196
|
+
if (before === undefined) {
|
|
197
|
+
yield* runPve(
|
|
198
|
+
news.target,
|
|
199
|
+
'provision',
|
|
200
|
+
true,
|
|
201
|
+
storage.createStorage(toDistilledCreate(news)),
|
|
202
|
+
);
|
|
203
|
+
} else if (!matches(before, news)) {
|
|
204
|
+
yield* runPve(
|
|
205
|
+
news.target,
|
|
206
|
+
'provision',
|
|
207
|
+
true,
|
|
208
|
+
storage.putStorage(toDistilledUpdate(news)),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const after = dropUnreadable(yield* readStorage(news));
|
|
212
|
+
if (after === undefined) {
|
|
213
|
+
return yield* Effect.die(
|
|
214
|
+
new Error(
|
|
215
|
+
`storage/${news.storage}: the write returned no error but the storage is still ` +
|
|
216
|
+
'absent. PVE wraps every answer in {"data":...} and can report success on a ' +
|
|
217
|
+
'call that did nothing -- read back rather than trusting the status code.',
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return after;
|
|
222
|
+
}),
|
|
223
|
+
/**
|
|
224
|
+
* ⚠️ THIS REMOVES THE DEFINITION, NOT THE DATA. PVE drops the section from storage.cfg and
|
|
225
|
+
* leaves the volumes on disk untouched — so the loss is not the bytes, it is that guests
|
|
226
|
+
* whose config still names this storage can no longer resolve their own disks.
|
|
227
|
+
*/
|
|
228
|
+
delete: Effect.fn(function* ({ olds }) {
|
|
229
|
+
yield* runPve(
|
|
230
|
+
olds.target,
|
|
231
|
+
'provision',
|
|
232
|
+
true,
|
|
233
|
+
storage.deleteStorage({ storage: olds.storage }),
|
|
234
|
+
);
|
|
235
|
+
}),
|
|
236
|
+
}),
|
|
237
|
+
),
|
|
238
|
+
);
|
|
@@ -138,4 +138,30 @@ describe('adopting an adopt-only ZfsPool declaration', () => {
|
|
|
138
138
|
const r: ZfsRaidLevel = 'stripe';
|
|
139
139
|
expect(String(r)).toBe('stripe');
|
|
140
140
|
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* ⛔ FOUND BY ADVERSARIAL REVIEW, 2026-09-24: a genuinely FIRST-EVER declaration never reaches
|
|
144
|
+
* `diff` at all (upstream `Plan.ts` routes `oldState === undefined` straight to `create`), so
|
|
145
|
+
* the vendor-constraint check `diff` runs had never once protected a brand-new pool's own
|
|
146
|
+
* create -- only an ADOPT's later plans. `createPool` (zfs-pool-form.ts) now re-checks before
|
|
147
|
+
* its own POST, the same pattern `ceph-pool.ts`'s hand-written `reconcile` already uses.
|
|
148
|
+
*/
|
|
149
|
+
test('a malformed ashift on a brand-new (non-adopt) declaration is refused before any write', async () => {
|
|
150
|
+
const fake = cluster(new Set());
|
|
151
|
+
await withoutBao(async () => {
|
|
152
|
+
const engine = engineFor(fake);
|
|
153
|
+
const bad = () =>
|
|
154
|
+
ProxmoxZfsPool('bad', {
|
|
155
|
+
// ⛔ PVE's own bound is 9-16 (generated/constraints/pve-nodes-disks.ts) -- 99 is refused.
|
|
156
|
+
ashift: 99,
|
|
157
|
+
devices: ['/dev/disk/by-id/fake-0'],
|
|
158
|
+
name: 'newpool',
|
|
159
|
+
node: 'node-b',
|
|
160
|
+
raidlevel: 'single',
|
|
161
|
+
target: FAKE_TARGET,
|
|
162
|
+
});
|
|
163
|
+
await expect(engine.deploy(bad())).rejects.toBeDefined();
|
|
164
|
+
});
|
|
165
|
+
expect(fake.writes()).toEqual([]);
|
|
166
|
+
});
|
|
141
167
|
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The create lane for `Proxmox.ZfsPool`: the form PVE wants, and the wait for the worker it forks.
|
|
3
|
+
* Split out of zfs-pool.ts (2026-09-24, the distilled migration) — same seam as before, now naming
|
|
4
|
+
* the write side the way storage.ts/storage-form.ts does.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ `createPool` TAKES THE READ AS A PARAMETER, GENERIC IN ITS ERROR AND REQUIREMENT CHANNELS,
|
|
7
|
+
* rather than importing the read module and coupling to its exact effect shape here. This file
|
|
8
|
+
* answers "how does a declaration become a pool on disks"; zfs-pool-wire.ts answers "what is a
|
|
9
|
+
* zpool, and when has it changed" — the caller (zfs-pool.ts) is what ties the two together, so
|
|
10
|
+
* this file cannot accidentally read the cluster differently from `diff`.
|
|
11
|
+
*/
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
14
|
+
import { asForm } from './distilled-guard.ts';
|
|
15
|
+
import { guardForm } from './constraint-guard.ts';
|
|
16
|
+
import { runPve } from './distilled-pve.ts';
|
|
17
|
+
import { text } from './values.ts';
|
|
18
|
+
import type { ZfsPoolAttributes, ZfsPoolProps } from './zfs-pool.ts';
|
|
19
|
+
import type { NodesNodeDisksZfsPostParams } from './generated/pve.ts';
|
|
20
|
+
|
|
21
|
+
export const ZFS_POOL_CREATE = 'pve:POST /nodes/{node}/disks/zfs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* PVE's layouts, generated (2026-09-23) from `NodesNodeDisksZfsPostParams['raidlevel']`
|
|
25
|
+
* (`generated/pve.ts`, manifest `pve-apidoc` pve-manager 9.2.11 sha256 9def8f13…) rather than
|
|
26
|
+
* hand-typed, per decision 9. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an
|
|
27
|
+
* even one. ⛔ THERE IS NO `stripe` IN THIS ENUM. n1's `speed` pool is one (decision 14 accepts it
|
|
28
|
+
* as-is): `ZfsPoolProps` (zfs-pool.ts) is why a stripe pool is declarable at all — as an
|
|
29
|
+
* ADOPT-ONLY declaration with no `raidlevel`, never as a full one.
|
|
30
|
+
*/
|
|
31
|
+
export type ZfsRaidLevel = NonNullable<NodesNodeDisksZfsPostParams['raidlevel']>;
|
|
32
|
+
|
|
33
|
+
/** Generated the same way, from `NodesNodeDisksZfsPostParams['compression']`. */
|
|
34
|
+
export type ZfsCompression = NonNullable<NodesNodeDisksZfsPostParams['compression']>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* ★ CHECKED AGAINST THE VENDOR TABLE ONLY FOR A FULL DECLARATION — decision 9's adopt-only mode.
|
|
38
|
+
* `guardCreate` (distilled-guard.ts's `guardWrite`) runs on the FIRST plan of ANY declaration and
|
|
39
|
+
* checks the create form's REQUIRED fields whenever this resolves to the real endpoint key — and
|
|
40
|
+
* PVE marks both `devices` and `raidlevel` required (codegen/constraints's `pve-nodes-disks`
|
|
41
|
+
* row). An adopt-only declaration deliberately omits both, so checking it would refuse a MATCHING
|
|
42
|
+
* adoption at plan time, before `read` ever ran. Returning `undefined` (no key) makes the guard a
|
|
43
|
+
* no-op for an adopt-only declaration, while a FULL one (`devices` and `raidlevel` both present)
|
|
44
|
+
* is checked exactly as before — zfs-pool.ts's `diff` passes this as the `guardWrite` endpoint.
|
|
45
|
+
* ⚠️ THE RUNTIME BACKSTOP IS `createPool` BELOW, NOT THIS FUNCTION. Skipping the plan-time guard
|
|
46
|
+
* for an adopt-only declaration does not skip the check that the pool is actually there —
|
|
47
|
+
* `createPool` refuses before any POST when the read comes back absent and either field is
|
|
48
|
+
* undefined, which is what a genuinely absent adopt-only declaration hits at apply time.
|
|
49
|
+
*/
|
|
50
|
+
export const zfsPoolCreateEndpoint = (props: ZfsPoolProps) =>
|
|
51
|
+
props.devices === undefined || props.raidlevel === undefined ? undefined : ZFS_POOL_CREATE;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The wire form, PVE's own field names — what `guardWrite` (zfs-pool.ts) checks.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ `devices` IS JOINED IN DECLARED ORDER AND NOT PUT THROUGH `csv()` FROM values.ts. `csv` sorts,
|
|
57
|
+
* and PVE walks this list two at a time to pair `raid10` mirrors — MEASURED in the node's own
|
|
58
|
+
* `PVE::API2::Disks::ZFS` — so sorting it builds a different pool from the one declared. The list
|
|
59
|
+
* is never compared against anything, so it needs no normalisation, only faithful order.
|
|
60
|
+
* ⚠️ AN OMITTED OPTIONAL IS NOT SENT. PVE's own defaults (ashift 12, compression on) are then
|
|
61
|
+
* applied by the node. Sending a guessed default would be indistinguishable here — the values
|
|
62
|
+
* are unreadable afterwards either way — but it would put a number in the request that no
|
|
63
|
+
* declaration asked for, and this is the one call that writes to physical disks.
|
|
64
|
+
* ⚠️ `node` IS NOT IN THE FORM: it is the `nodes/{node}` path label, and a second copy could only
|
|
65
|
+
* disagree with it.
|
|
66
|
+
* ⚠️ EVERY KEY IS OMITTED WHEN ABSENT, `devices` AND `raidlevel` INCLUDED — not just the optional
|
|
67
|
+
* ones. An adopt-only declaration (both undefined, decision 9) must produce a form `createPool`'s
|
|
68
|
+
* own guard can inspect for their absence, and one this function itself does not throw building.
|
|
69
|
+
*/
|
|
70
|
+
export const createForm = (props: ZfsPoolProps): Record<string, string> => ({
|
|
71
|
+
...(props.devices === undefined ? {} : { devices: props.devices.join(',') }),
|
|
72
|
+
name: props.name,
|
|
73
|
+
...(props.raidlevel === undefined ? {} : { raidlevel: props.raidlevel }),
|
|
74
|
+
...(props.ashift === undefined ? {} : { ashift: String(props.ashift) }),
|
|
75
|
+
...(props.compression === undefined ? {} : { compression: props.compression }),
|
|
76
|
+
...(props['draid-config'] === undefined ? {} : { 'draid-config': props['draid-config'] }),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The actual `createNodeDiskZfs` call body — `createForm` plus the `node` label, `draid-config`
|
|
81
|
+
* renamed to distilled's `draid_config` (`T.Body("draid-config")` on the generated schema; the
|
|
82
|
+
* ONE hyphenated field this family's create form carries — storage-form.ts's `underscored` has
|
|
83
|
+
* the fuller measurement, not repeated here for one field).
|
|
84
|
+
*
|
|
85
|
+
* ⚠️ `as unknown as` — `devices`/`raidlevel` are REQUIRED in distilled's generated
|
|
86
|
+
* `CreateNodeDiskZfsRequest`, so an adopt-only form (both omitted) cannot satisfy it structurally
|
|
87
|
+
* even though it is a valid PVE request for `guardWrite`'s purposes. `createPool` below refuses
|
|
88
|
+
* before ever reaching this cast when either is genuinely missing, so the cast never carries a
|
|
89
|
+
* request distilled's OWN required fields would reject in practice.
|
|
90
|
+
*/
|
|
91
|
+
const toDistilledCreate = (props: ZfsPoolProps): nodes.CreateNodeDiskZfsRequest => {
|
|
92
|
+
const form = createForm(props);
|
|
93
|
+
return {
|
|
94
|
+
...form,
|
|
95
|
+
draid_config: form['draid-config'],
|
|
96
|
+
node: props.node,
|
|
97
|
+
} as unknown as nodes.CreateNodeDiskZfsRequest;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** 30 reads, 2s apart. A create that has not landed in a minute has gone wrong, not gone slow. */
|
|
101
|
+
const SETTLE_ATTEMPTS = 30;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create the pool if it is absent, and otherwise leave it completely alone.
|
|
105
|
+
*
|
|
106
|
+
* ⛔ THE POST ONLY FORKS A WORKER. MEASURED in `/usr/share/perl5/PVE/API2/Disks/ZFS.pm` on node-b:
|
|
107
|
+
* the create handler ends in `$rpcenv->fork_worker('zfscreate', ...)` and its HTTP answer is a
|
|
108
|
+
* UPID returned the instant the worker is forked — before `zpool create` has run, let alone
|
|
109
|
+
* finished. A read-back taken immediately would report a successful create as the failure "the
|
|
110
|
+
* write returned no error but the object is still absent". THAT is why this function polls.
|
|
111
|
+
*
|
|
112
|
+
* ⚠️ THERE IS NO UPDATE BRANCH BECAUSE PVE HAS NO PUT HERE. An existing pool is returned exactly as
|
|
113
|
+
* read, unwritten: the only write this resource ever makes is the create of a pool that is not
|
|
114
|
+
* there. Do not add a "repair" write; there is no field it could set.
|
|
115
|
+
*
|
|
116
|
+
* ⛔ AND IF THE READ IS WRONG, THE POST IS STILL SAFE — measured, not hoped for. `readPool` folds a
|
|
117
|
+
* 403 or an unreachable node into "absent", so a too-narrow lease would send this down the create
|
|
118
|
+
* path over a live pool. PVE's handler calls `get_pool_data()` before it forks anything and dies
|
|
119
|
+
* with "pool '<name>' already exists on node '<node>'", and `assert_disk_unused` refuses every
|
|
120
|
+
* device that pool is holding. A create aimed at a pool that is really there fails loudly rather
|
|
121
|
+
* than wiping it.
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ SHORT POLLS WITH A HARD CAP, NOT ONE LONG WAIT, NOT `@distilled.cloud/proxmox`'s `awaitTask`.
|
|
124
|
+
* `readPool` cannot tell "not yet" from "forbidden" — both fold to `undefined` — so the loop
|
|
125
|
+
* gives up with the UPID rather than retrying forever, and the UPID is the only thing that leads
|
|
126
|
+
* to the worker's real error. Distilled's own task-status poller exists (`Task.ts`) and would
|
|
127
|
+
* remove this ambiguity, but swapping to it changes this resource's OBSERVABLE behaviour (what a
|
|
128
|
+
* caller sees on a slow create) beyond a transport swap — left as a follow-up, not folded into a
|
|
129
|
+
* migration PR silently.
|
|
130
|
+
*
|
|
131
|
+
* ⛔ THE SAME AMBIGUITY MAKES A RE-RUN AFTER THE GIVE-UP DIE GENUINELY UNSAFE, NOT MERELY SLOW TO
|
|
132
|
+
* CONFIRM — FOUND BY ADVERSARIAL REVIEW, 2026-09-24, of THIS migration, though the shape is
|
|
133
|
+
* unchanged from before it (`git blame` shows the same settle-poll pre-dates this PR). "If the
|
|
134
|
+
* read is wrong, the POST is still safe" above is true for the FIRST call into this function —
|
|
135
|
+
* PVE's own `get_pool_data()` refuses a create over a pool it can already see. It is NOT true
|
|
136
|
+
* for a SECOND deploy attempt started while the FIRST worker is still mid-`zpool create`: that
|
|
137
|
+
* worker has not yet made the pool visible to a read, so `existing = yield* read(props)` at the
|
|
138
|
+
* top of THIS call can also fold to `undefined` — not because the pool is gone, but because the
|
|
139
|
+
* first attempt has not finished — and this function would POST a SECOND `zpool create` at the
|
|
140
|
+
* SAME devices while the first is still writing them. There is no lock or marker anywhere in
|
|
141
|
+
* this package that would stop it. The give-up message below says so explicitly and names the
|
|
142
|
+
* task log to check FIRST; that message, and an operator reading it, are the only safeguard.
|
|
143
|
+
*
|
|
144
|
+
* ⛔ THE ADOPT-ONLY GUARD, RIGHT AFTER THE READ AND BEFORE ANYTHING ELSE. Decision 9 (2026-09-23):
|
|
145
|
+
* omitting `devices` and `raidlevel` declares a pool this resource may only ADOPT, for a pool
|
|
146
|
+
* PVE's own schema cannot fully describe — n1's `speed` is a stripe, and `raidlevel`'s enum has
|
|
147
|
+
* no stripe (zfs-pool.ts). `zfsPoolCreateEndpoint` above already keeps the vendor constraint
|
|
148
|
+
* guard from refusing such a declaration at PLAN time; this is the matching refusal at APPLY
|
|
149
|
+
* time, for the one case that guard cannot see — the pool genuinely is not there.
|
|
150
|
+
*/
|
|
151
|
+
export const createPool = <E, R>(
|
|
152
|
+
props: ZfsPoolProps,
|
|
153
|
+
read: (props: ZfsPoolProps) => Effect.Effect<ZfsPoolAttributes | undefined, E, R>,
|
|
154
|
+
) =>
|
|
155
|
+
Effect.gen(function* () {
|
|
156
|
+
const existing = yield* read(props);
|
|
157
|
+
if (existing !== undefined) return existing;
|
|
158
|
+
|
|
159
|
+
if (props.devices === undefined || props.raidlevel === undefined) {
|
|
160
|
+
return yield* Effect.die(
|
|
161
|
+
new Error(
|
|
162
|
+
`${props.node}/${props.name}: this is an adopt-only declaration (no \`devices\` or ` +
|
|
163
|
+
'`raidlevel`) and the pool is not there to adopt. An adopt-only Proxmox.ZfsPool never ' +
|
|
164
|
+
'creates -- declare both fields for a pool this resource should build, or point this ' +
|
|
165
|
+
'declaration at a pool that already exists on this node.',
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* ⛔ FOUND BY ADVERSARIAL REVIEW, 2026-09-24 — `diff` NEVER RUNS FOR A GENUINELY FIRST-EVER
|
|
172
|
+
* DECLARATION. Upstream `Plan.ts` routes a brand-new resource (`oldState === undefined`)
|
|
173
|
+
* straight to `action: 'create'` without ever calling `provider.diff` — so `zfs-pool.ts`'s
|
|
174
|
+
* own `guardForm` call (which checks THIS SAME form) never runs for that plan, and this was
|
|
175
|
+
* THE ONLY WRITE PATH IN THE FAMILY WITH NO VENDOR CHECK AT ALL, before or after this
|
|
176
|
+
* migration. `ceph-pool.ts`'s own hand-written `reconcile` already re-runs its create guard
|
|
177
|
+
* for exactly this reason (an ADOPTED row's `diff` answer is discarded by Plan.ts too); this
|
|
178
|
+
* is the same fix for this family. A malformed `ashift` or `draid-config` is now refused
|
|
179
|
+
* here, before the POST, rather than reaching the wire and refused only by PVE itself.
|
|
180
|
+
*/
|
|
181
|
+
yield* guardForm(zfsPoolCreateEndpoint(props), asForm(createForm(props)), true);
|
|
182
|
+
|
|
183
|
+
const upid = yield* runPve(
|
|
184
|
+
props.target,
|
|
185
|
+
'provision',
|
|
186
|
+
true,
|
|
187
|
+
nodes.createNodeDiskZfs(toDistilledCreate(props)),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const settle = (attempts: number): Effect.Effect<ZfsPoolAttributes | undefined, E, R> =>
|
|
191
|
+
Effect.gen(function* () {
|
|
192
|
+
const live = yield* read(props);
|
|
193
|
+
if (live !== undefined || attempts <= 0) return live;
|
|
194
|
+
yield* Effect.sleep('2 seconds');
|
|
195
|
+
return yield* settle(attempts - 1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const after = yield* settle(SETTLE_ATTEMPTS);
|
|
199
|
+
if (after === undefined) {
|
|
200
|
+
return yield* Effect.die(
|
|
201
|
+
new Error(
|
|
202
|
+
`nodes/${props.node}/disks/zfs/${props.name}: POST returned ${text(upid, '(no UPID)')} ` +
|
|
203
|
+
'but the pool has not appeared. That POST only forks a `zfscreate` worker, so the task ' +
|
|
204
|
+
`holds the real error -- read it with \`pvesh get /nodes/${props.node}/tasks/<upid>/log\`. ` +
|
|
205
|
+
'NOTHING WAS RETRIED AND NOTHING WAS CLEANED UP: if the worker reached `zpool create`, ' +
|
|
206
|
+
'the declared devices have already been written to. DO NOT RE-RUN THIS DEPLOY BEFORE ' +
|
|
207
|
+
'READING THAT LOG: `read` cannot tell "the worker is still running" from "the pool is ' +
|
|
208
|
+
'genuinely gone", so a re-run this soon can POST a SECOND `zpool create` at the SAME ' +
|
|
209
|
+
'devices while the first one is still mid-write -- confirm the task finished (one way ' +
|
|
210
|
+
'or the other) before trying again.',
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return after;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* ⛔ `DELETE /nodes/{node}/disks/zfs/{name}` RUNS `zpool destroy` AND THE DATASETS GO WITH IT. Per
|
|
219
|
+
* S14 (alchemy-provider-standard), it is implemented rather than stubbed — a `delete` that
|
|
220
|
+
* silently does nothing lies to whoever reads the plan — and what keeps an ordinary removed
|
|
221
|
+
* declaration from reaching it is the resource's `defaultRemovalPolicy: 'retain'` (zfs-pool.ts):
|
|
222
|
+
* an orphaned pool is forgotten, not destroyed, unless a caller opts in with
|
|
223
|
+
* `.pipe(RemovalPolicy.destroy())`. See the corrected header in zfs-pool.ts.
|
|
224
|
+
*
|
|
225
|
+
* ⚠️ `cleanup-config`/`cleanup-disks` ARE NOT SENT. The first would additionally remove the PVE
|
|
226
|
+
* storage entry pointing at this pool, which is a different object with its own resource
|
|
227
|
+
* (`Proxmox.Storage`); one resource reaching over to delete another's is how a stack ends up with
|
|
228
|
+
* state describing something that is gone. Neither is offered as a prop, for the same reason.
|
|
229
|
+
*/
|
|
230
|
+
export const destroyPool = (props: ZfsPoolProps) =>
|
|
231
|
+
runPve(
|
|
232
|
+
props.target,
|
|
233
|
+
'provision',
|
|
234
|
+
true,
|
|
235
|
+
nodes.deleteNodeDiskZfs({ name: props.name, node: props.node }),
|
|
236
|
+
);
|