@homeflare/alchemy 0.31.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.md +44 -7
  2. package/dist/grafana/index.d.ts +6 -5
  3. package/dist/grafana/index.d.ts.map +1 -1
  4. package/dist/linux/container-form.d.ts +129 -0
  5. package/dist/linux/container-form.d.ts.map +1 -0
  6. package/dist/linux/container-generator.d.ts +74 -0
  7. package/dist/linux/container-generator.d.ts.map +1 -0
  8. package/dist/linux/container-handlers.d.ts +15 -0
  9. package/dist/linux/container-handlers.d.ts.map +1 -0
  10. package/dist/linux/container-lifecycle.d.ts +37 -0
  11. package/dist/linux/container-lifecycle.d.ts.map +1 -0
  12. package/dist/linux/container-preflight.d.ts +34 -0
  13. package/dist/linux/container-preflight.d.ts.map +1 -0
  14. package/dist/linux/container-secrets.d.ts +37 -0
  15. package/dist/linux/container-secrets.d.ts.map +1 -0
  16. package/dist/linux/container-settle.d.ts +34 -0
  17. package/dist/linux/container-settle.d.ts.map +1 -0
  18. package/dist/linux/container.d.ts +30 -0
  19. package/dist/linux/container.d.ts.map +1 -0
  20. package/dist/linux/fake-quadlet.d.ts +25 -0
  21. package/dist/linux/fake-quadlet.d.ts.map +1 -0
  22. package/dist/linux/index.d.ts +6 -3
  23. package/dist/linux/index.d.ts.map +1 -1
  24. package/dist/linux/index.js +641 -268
  25. package/dist/linux/index.js.map +15 -7
  26. package/dist/linux/providers.d.ts +1 -1
  27. package/dist/linux/providers.d.ts.map +1 -1
  28. package/dist/linux/systemctl.d.ts +7 -1
  29. package/dist/linux/systemctl.d.ts.map +1 -1
  30. package/dist/opnsense/alias-form.d.ts +29 -0
  31. package/dist/opnsense/alias-form.d.ts.map +1 -0
  32. package/dist/opnsense/alias.d.ts +81 -0
  33. package/dist/opnsense/alias.d.ts.map +1 -0
  34. package/dist/opnsense/category-form.d.ts +10 -0
  35. package/dist/opnsense/category-form.d.ts.map +1 -0
  36. package/dist/opnsense/category.d.ts +58 -0
  37. package/dist/opnsense/category.d.ts.map +1 -0
  38. package/dist/opnsense/credentials.d.ts +36 -0
  39. package/dist/opnsense/credentials.d.ts.map +1 -0
  40. package/dist/opnsense/fake-opnsense.d.ts +51 -0
  41. package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
  42. package/dist/opnsense/group-form.d.ts +11 -0
  43. package/dist/opnsense/group-form.d.ts.map +1 -0
  44. package/dist/opnsense/group.d.ts +62 -0
  45. package/dist/opnsense/group.d.ts.map +1 -0
  46. package/dist/opnsense/index.d.ts +16 -0
  47. package/dist/opnsense/index.d.ts.map +1 -0
  48. package/dist/opnsense/index.js +211 -0
  49. package/dist/opnsense/index.js.map +20 -0
  50. package/dist/opnsense/policy.d.ts +20 -0
  51. package/dist/opnsense/policy.d.ts.map +1 -0
  52. package/dist/opnsense/providers.d.ts +13 -0
  53. package/dist/opnsense/providers.d.ts.map +1 -0
  54. package/dist/opnsense/resource.d.ts +74 -0
  55. package/dist/opnsense/resource.d.ts.map +1 -0
  56. package/dist/opnsense/wire.d.ts +39 -0
  57. package/dist/opnsense/wire.d.ts.map +1 -0
  58. package/dist/proxmox/acl.d.ts.map +1 -1
  59. package/dist/proxmox/api-token.d.ts.map +1 -1
  60. package/dist/proxmox/group-wire.d.ts +44 -5
  61. package/dist/proxmox/group-wire.d.ts.map +1 -1
  62. package/dist/proxmox/group.d.ts.map +1 -1
  63. package/dist/proxmox/index.js +326 -264
  64. package/dist/proxmox/index.js.map +13 -11
  65. package/dist/proxmox/role.d.ts.map +1 -1
  66. package/dist/proxmox/storage-form.d.ts +54 -0
  67. package/dist/proxmox/storage-form.d.ts.map +1 -0
  68. package/dist/proxmox/storage-wire.d.ts +56 -0
  69. package/dist/proxmox/storage-wire.d.ts.map +1 -0
  70. package/dist/proxmox/storage.d.ts +55 -60
  71. package/dist/proxmox/storage.d.ts.map +1 -1
  72. package/dist/proxmox/user-wire.d.ts +34 -5
  73. package/dist/proxmox/user-wire.d.ts.map +1 -1
  74. package/dist/proxmox/user.d.ts +6 -6
  75. package/dist/proxmox/user.d.ts.map +1 -1
  76. package/dist/unifi/fake-unifi.d.ts +48 -0
  77. package/dist/unifi/fake-unifi.d.ts.map +1 -0
  78. package/dist/unifi/firewall-zone-form.d.ts +40 -0
  79. package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
  80. package/dist/unifi/firewall-zone.d.ts +50 -0
  81. package/dist/unifi/firewall-zone.d.ts.map +1 -0
  82. package/dist/unifi/index.d.ts +13 -0
  83. package/dist/unifi/index.d.ts.map +1 -0
  84. package/dist/unifi/index.js +190 -0
  85. package/dist/unifi/index.js.map +16 -0
  86. package/dist/unifi/network-form.d.ts +67 -0
  87. package/dist/unifi/network-form.d.ts.map +1 -0
  88. package/dist/unifi/network.d.ts +67 -0
  89. package/dist/unifi/network.d.ts.map +1 -0
  90. package/dist/unifi/policy.d.ts +20 -0
  91. package/dist/unifi/policy.d.ts.map +1 -0
  92. package/dist/unifi/providers.d.ts +13 -0
  93. package/dist/unifi/providers.d.ts.map +1 -0
  94. package/dist/unifi/resource.d.ts +64 -0
  95. package/dist/unifi/resource.d.ts.map +1 -0
  96. package/dist/verify/fake-engine.d.ts +14 -0
  97. package/dist/verify/fake-engine.d.ts.map +1 -1
  98. package/docs/grafana.md +47 -17
  99. package/docs/opnsense.md +146 -0
  100. package/docs/provider-standard.md +1 -1
  101. package/docs/quadlet-container.md +132 -0
  102. package/docs/unifi.md +93 -0
  103. package/docs/upstream-conformance.md +14 -4
  104. package/package.json +13 -3
  105. package/src/grafana/index.ts +6 -5
  106. package/src/linux/container-fixture.test.ts +92 -0
  107. package/src/linux/container-form.ts +243 -0
  108. package/src/linux/container-generator.test.ts +195 -0
  109. package/src/linux/container-generator.ts +113 -0
  110. package/src/linux/container-handlers.ts +73 -0
  111. package/src/linux/container-lifecycle.test.ts +209 -0
  112. package/src/linux/container-lifecycle.ts +217 -0
  113. package/src/linux/container-preflight.ts +97 -0
  114. package/src/linux/container-secrets.test.ts +171 -0
  115. package/src/linux/container-secrets.ts +112 -0
  116. package/src/linux/container-settle.ts +97 -0
  117. package/src/linux/container.ts +60 -0
  118. package/src/linux/fake-quadlet.ts +127 -0
  119. package/src/linux/index.ts +11 -3
  120. package/src/linux/providers.ts +2 -0
  121. package/src/linux/systemctl.ts +9 -0
  122. package/src/opnsense/alias-form.test.ts +89 -0
  123. package/src/opnsense/alias-form.ts +53 -0
  124. package/src/opnsense/alias.test.ts +203 -0
  125. package/src/opnsense/alias.ts +117 -0
  126. package/src/opnsense/category-form.test.ts +54 -0
  127. package/src/opnsense/category-form.ts +20 -0
  128. package/src/opnsense/category.test.ts +138 -0
  129. package/src/opnsense/category.ts +82 -0
  130. package/src/opnsense/credentials.ts +41 -0
  131. package/src/opnsense/fake-opnsense.ts +79 -0
  132. package/src/opnsense/group-form.test.ts +75 -0
  133. package/src/opnsense/group-form.ts +25 -0
  134. package/src/opnsense/group.test.ts +141 -0
  135. package/src/opnsense/group.ts +78 -0
  136. package/src/opnsense/index.ts +45 -0
  137. package/src/opnsense/policy.ts +49 -0
  138. package/src/opnsense/providers.ts +21 -0
  139. package/src/opnsense/resource.ts +146 -0
  140. package/src/opnsense/wire.ts +56 -0
  141. package/src/opnsense/write-refusal.test.ts +148 -0
  142. package/src/proxmox/acl.ts +10 -4
  143. package/src/proxmox/api-token.ts +20 -12
  144. package/src/proxmox/group-wire.ts +62 -8
  145. package/src/proxmox/group.ts +23 -35
  146. package/src/proxmox/read-failure-propagation.test.ts +183 -0
  147. package/src/proxmox/role.ts +9 -2
  148. package/src/proxmox/storage-form.ts +144 -0
  149. package/src/proxmox/storage-read-failure.test.ts +214 -0
  150. package/src/proxmox/storage-wire.ts +124 -0
  151. package/src/proxmox/storage.ts +165 -175
  152. package/src/proxmox/user-wire.ts +44 -8
  153. package/src/proxmox/user.ts +29 -27
  154. package/src/unifi/fake-unifi.ts +60 -0
  155. package/src/unifi/firewall-zone-form.ts +65 -0
  156. package/src/unifi/firewall-zone.test.ts +88 -0
  157. package/src/unifi/firewall-zone.ts +66 -0
  158. package/src/unifi/index.ts +26 -0
  159. package/src/unifi/network-form.ts +207 -0
  160. package/src/unifi/network.test.ts +209 -0
  161. package/src/unifi/network.ts +83 -0
  162. package/src/unifi/policy.ts +37 -0
  163. package/src/unifi/providers.ts +15 -0
  164. package/src/unifi/resource.test.ts +133 -0
  165. package/src/unifi/resource.ts +131 -0
  166. package/src/verify/fake-engine.ts +23 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `Proxmox.Storage`'s READ side: how a live item becomes attributes, the read itself, and
3
+ * `matches`. Split out of storage.ts (2026-09-24, the distilled migration); the write side is
4
+ * storage-form.ts (that file's own header has the reasoning) — both stay under the 250-line cap,
5
+ * the api-token.ts/api-token-form.ts seam.
6
+ */
7
+ import * as storage from '@distilled.cloud/proxmox/storage';
8
+ import * as Effect from 'effect/Effect';
9
+ import { runPve } from './distilled-pve.ts';
10
+ import type { StorageProps } from './storage.ts';
11
+ import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
12
+ import { bool } from './values.ts';
13
+
14
+ export interface StorageAttributes {
15
+ storage: string;
16
+ /** Reported so a plan can show what it points at; never diffed — storage.ts's `type` doc. */
17
+ type: string;
18
+ content: string;
19
+ nodes: string;
20
+ disable: boolean;
21
+ shared: boolean;
22
+ preallocation: string;
23
+ 'prune-backups': string;
24
+ comment: string;
25
+ }
26
+
27
+ const str = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback);
28
+
29
+ /**
30
+ * ⚠️ PVE RE-SERIALISES ITS LIST FIELDS IN ITS OWN ORDER — `content` and `nodes` are parsed into a
31
+ * set and written back sorted, `prune-backups` is a property string that comes back in schema
32
+ * order. Comparing raw strings would report an update for `iso,backup` against `backup,iso` on
33
+ * every plan, and the PUT would "fix" it into the identical string. Trimming matters too: a
34
+ * declared `iso, backup` would otherwise never equal what PVE stores.
35
+ */
36
+ const tokens = (value: string) =>
37
+ value
38
+ .split(',')
39
+ .map((part) => part.trim())
40
+ .filter((part) => part !== '')
41
+ .sort()
42
+ .join(',');
43
+
44
+ export const sameList = (declared: string | undefined, live: string) =>
45
+ declared === undefined || tokens(declared) === tokens(live);
46
+
47
+ export const same = <T>(declared: T | undefined, live: T) =>
48
+ declared === undefined || declared === live;
49
+
50
+ /**
51
+ * ⚠️ `digest` IS DELIBERATELY NOT AN ATTRIBUTE. PVE returns one on every storage read and it
52
+ * covers storage.cfg as a FILE, not this section of it, so keeping it would rewrite this
53
+ * resource's state whenever an unrelated storage was edited — churn that reads like drift.
54
+ * ⛔ `maxfiles` IS NOT REPORTED, AND `StorageProps.maxfiles` NO LONGER EXISTS — see storage.ts's
55
+ * header for the measured SDK gap this reflects.
56
+ */
57
+ const attributesOf = (live: unknown, props: StorageProps): StorageAttributes => {
58
+ const data = live as Record<string, unknown>;
59
+ return {
60
+ comment: str(data['comment']),
61
+ content: str(data['content']),
62
+ disable: bool(data['disable']),
63
+ nodes: str(data['nodes']),
64
+ preallocation: str(data['preallocation']),
65
+ 'prune-backups': str(data['prune-backups']),
66
+ shared: bool(data['shared']),
67
+ storage: props.storage,
68
+ type: str(data['type'], props.type),
69
+ };
70
+ };
71
+
72
+ /**
73
+ * ★ Default `read` role WOULD READ 403 — see storage.ts's header on why this family reads with
74
+ * `provision`, the same as acl.ts and unlike group.ts/user.ts.
75
+ * ⛔ `{ storage: props.storage }`, NEVER THE WHOLE `props` — user.ts/group.ts's own ⛔, the
76
+ * kit 0.31.1 regression: `GetStorageRequest`'s schema declares only `storage` (a path label);
77
+ * any OTHER key on the object passed to `storage.getStorage` is treated by distilled's
78
+ * `buildRequest` as an "unknown key" and JSON-encoded onto this bodyless GET, which a stricter
79
+ * fetch client refuses outright.
80
+ * ⛔ A MISSING STORAGE IS A 500, NOT A 404 — MEASURED against the live cluster (TB4, 2026-09-24,
81
+ * admin lane, read-only probe): `GET /storage/hf-measure-nonexistent-probe` answers
82
+ * `{"message":"storage 'hf-measure-nonexistent-probe' does not exist\n","data":null}` at HTTP
83
+ * 500 — the SAME shape as user.ts's and group.ts's own measured 500, not the clean
84
+ * `{"data":null}` at 200 this file's pre-migration comment assumed for every read failure
85
+ * (client.ts's `pveOperations.read` could not tell the two apart either way, folding both into
86
+ * "absent"). `readStorage` below (used by `read`/`reconcile`) still folds it, exactly as
87
+ * `readUser`/`readGroup` do, so a brand-new declaration can still be created; `readStorageOrFail`
88
+ * (used by `diff`) does not.
89
+ */
90
+ export const readStorageOrFail = (props: StorageProps) =>
91
+ readOrUnreadable(
92
+ runPve(props.target, 'provision', false, storage.getStorage({ storage: props.storage })),
93
+ ).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
94
+
95
+ /**
96
+ * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT —
97
+ * user.ts's `readUser` has the full reasoning. Safe here because a wrongful fold costs at most a
98
+ * redundant `createStorage` POST, which PVE refuses loudly ("storage ID already exists") rather
99
+ * than silently corrupting anything.
100
+ * ⛔ `storage.ts`'s `diff` does **NOT** use this — the SAME bug class as the credential denial fix:
101
+ * folding a TRANSIENT failure into "absent" at PLAN TIME is what forced a false `update` with
102
+ * nothing compared (the ORIGINAL cries-wolf bug this family shipped with, per unreadable-read.ts's
103
+ * own header). `diff` calls `readStorageOrFail` directly, so a genuine failure there propagates
104
+ * and fails the whole plan loudly instead.
105
+ */
106
+ export const readStorage = (props: StorageProps) =>
107
+ readStorageOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
108
+
109
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
110
+ export const dropUnreadable = (live: StorageAttributes | Unreadable | undefined) =>
111
+ live === UNREADABLE ? undefined : live;
112
+
113
+ /**
114
+ * ⚠️ `type` IS NEVER COMPARED — CREATE-ONLY (`storage.ts`'s own `StorageProps.type` doc), so a
115
+ * changed declaration plans `noop` on it, same as before the migration.
116
+ */
117
+ export const matches = (attributes: StorageAttributes, props: StorageProps) =>
118
+ sameList(props.content, attributes.content) &&
119
+ sameList(props.nodes, attributes.nodes) &&
120
+ sameList(props['prune-backups'], attributes['prune-backups']) &&
121
+ same(props.disable, attributes.disable) &&
122
+ same(props.shared, attributes.shared) &&
123
+ same(props.preallocation, attributes.preallocation) &&
124
+ same(props.comment, attributes.comment);
@@ -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)". `pveOperations.read` folds
16
- * every failure into `undefined`, i.e. "absent", because a 404 is a legitimate answer there and
17
- * it cannot tell the two apart — so under that lease this resource NEVER CONVERGES, in two
18
- * directions: reconcile reads "absent" and POSTs a create over a storage that is already defined,
19
- * and where a create does land the read-back is 403 too, so reconcile dies with "the write
20
- * returned no error but the object is still absent" about an object it has just built. Both
21
- * messages point away from the cause.
22
- * ★ SO WIDEN OR RE-POINT BEFORE THE FIRST PLAN, outside this file: give the credential mount's
23
- * `read` role `Datastore.Allocate` on `/storage`, or read this family with `provision`. The
24
- * provision role in the estate this was written for held only `Datastore.AllocateSpace` and
25
- * `Datastore.Audit`, and NEITHER IS IT — AllocateSpace writes volumes INTO a storage, Allocate
26
- * DEFINES one — so it 403'd here until widened the way `Pool.Allocate` was for `Proxmox.Pool`.
27
- * The provisioning baseline (`provision-baseline.ts`) carries `Datastore.Allocate`.
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 { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
41
- import { bool, flag } from './values.ts';
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
- * The create-time, plugin-specific half of a storage: `dir` wants `path`, `nfs` `server`+`export`,
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
- * ⚠️ THE DEPRECATED PREDECESSOR OF `prune-backups`, and the two do not mix — declare one or the
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(ProxmoxStorage, Effect.succeed(ProxmoxStorage.Provider.of(handlers)));
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
+ );
@@ -1,10 +1,13 @@
1
1
  /**
2
- * `Proxmox.User`'s wire shape: PVE's create/update forms and how a live item becomes
3
- * attributes. Split out of user.ts (2026-09-24, the distilled migration) to keep that file
4
- * under the 250-line cap — the api-token.ts/api-token-form.ts seam.
2
+ * `Proxmox.User`'s wire shape: PVE's create/update forms, how a live item becomes attributes,
3
+ * and the read itself. Split out of user.ts (2026-09-24, the distilled migration) to keep that
4
+ * file under the 250-line cap — the api-token.ts/api-token-form.ts seam.
5
5
  */
6
- import type * as access from '@distilled.cloud/proxmox/access';
6
+ import * as access from '@distilled.cloud/proxmox/access';
7
+ import * as Effect from 'effect/Effect';
8
+ import { runPve } from './distilled-pve.ts';
7
9
  import type { UserAttributes, UserProps } from './user.ts';
10
+ import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
8
11
  import { bool, text } from './values.ts';
9
12
 
10
13
  export const USER_CREATE = 'pve:POST /access/users';
@@ -77,10 +80,7 @@ export const updateForm = (props: UserProps): access.PutAccessUserRequest => ({
77
80
  userid: props.userid,
78
81
  });
79
82
 
80
- export const attributesOf = (
81
- live: access.GetAccessUserResponse,
82
- props: UserProps,
83
- ): UserAttributes => ({
83
+ const attributesOf = (live: access.GetAccessUserResponse, props: UserProps): UserAttributes => ({
84
84
  comment: text(live.comment),
85
85
  email: text(live.email),
86
86
  /**
@@ -110,3 +110,39 @@ export const matches = (attributes: UserAttributes, props: UserProps) =>
110
110
  attributes.firstname === (props.firstname ?? '') &&
111
111
  attributes.lastname === (props.lastname ?? '') &&
112
112
  attributes.groups.join(',') === groupSet(props.groups).join(',');
113
+
114
+ /**
115
+ * ★ Default `read` role for this family — only the write side needs `provision`.
116
+ * ⛔ `{ userid: props.userid }`, NEVER THE WHOLE `props` — MEASURED 2026-09-24, the regression in
117
+ * kit 0.31.1. `GetAccessUserRequest`'s schema declares only `userid` (a path label); any OTHER
118
+ * key on the object passed to `access.getAccessUser` — `target`, `comment`, every field this
119
+ * family's own props carry — is treated by distilled's `buildRequest` as an "unknown key" and
120
+ * JSON-encoded onto the request as a BODY, on every one of these bodyless GETs. A stricter
121
+ * fetch client than a bare `bun run` of this file refuses a GET carrying any body at all
122
+ * (`TypeError [ERR_INVALID_ARG_VALUE]: fetch() request with GET/HEAD method cannot have body`),
123
+ * which `runPveWith` retries across every member and exhausts identically —
124
+ * `PveClusterExhausted`, then silently folded to "absent" by the old `orElseSucceed`, forcing a
125
+ * false `update` on every account.
126
+ */
127
+ export const readUserOrFail = (props: UserProps) =>
128
+ readOrUnreadable(
129
+ runPve(props.target, 'read', false, access.getAccessUser({ userid: props.userid })),
130
+ ).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
131
+
132
+ /**
133
+ * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT. A
134
+ * missing user is a thrown 500 (user.ts's own header), so `reconcile`'s create branch NEEDS
135
+ * "absent" from a failure to ever run — without this fold, a brand-new account could never be
136
+ * created. Safe here because a wrongful fold costs at most a redundant `createAccessUser` POST,
137
+ * which PVE refuses loudly rather than silently corrupting anything.
138
+ * ⛔ `user.ts`'s `diff` does **NOT** use this — the SAME bug class as the credential denial fix:
139
+ * folding a TRANSIENT failure into "absent" at PLAN TIME is what forced a false `update` with
140
+ * nothing compared. `diff` calls `readUserOrFail` directly, so a genuine failure there
141
+ * propagates and fails the whole plan loudly instead.
142
+ */
143
+ export const readUser = (props: UserProps) =>
144
+ readUserOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
145
+
146
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
147
+ export const dropUnreadable = (live: UserAttributes | Unreadable | undefined) =>
148
+ live === UNREADABLE ? undefined : live;