@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,183 @@
1
+ /**
2
+ * The 0.31.1 regression (measured live 2026-09-24, homeflare-proxmox `bun run plan`): every
3
+ * `Proxmox.User`/`Proxmox.Group` row planned `update` with no warning, because their reads were
4
+ * silently folding EVERY failure — not only a refused credential — into "absent". Two fixes,
5
+ * both pinned here with REAL-SHAPED fixtures (live response bodies, nothing secret):
6
+ *
7
+ * 1. `access.getAccessUser`/`getAccessGroup` must be called with ONLY the schema's own label
8
+ * field, never the whole declared props object — the root cause: distilled's `buildRequest`
9
+ * treats every OTHER key as an "unknown key" and attaches it as a JSON body to what must stay
10
+ * a bodyless GET, which a stricter fetch client refuses outright.
11
+ * 2. `diff` must never fold a genuine (non-credential-denial) read failure into "absent" — only
12
+ * `read`/`reconcile` may, where a wrongful fold costs at most a redundant, loudly-refused
13
+ * create, never a silent wrong write.
14
+ */
15
+ import { describe, expect, test } from 'bun:test';
16
+ import * as Layer from 'effect/Layer';
17
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
18
+ import { engineOver } from '../verify/fake-engine.ts';
19
+ import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
20
+ import { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
21
+ import { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
22
+
23
+ /**
24
+ * A fake cluster whose GET on `path` answers 200 with `live`, but REFUSES (500, mimicking PVE's
25
+ * real "InternalServerError" shape) once `transientFrom` is `true` — and asserts no request ever
26
+ * carries a body on a GET, the request-shape regression's own signature.
27
+ */
28
+ const clusterWithTransientFailure = (path: string, live: unknown) => {
29
+ let transient = false;
30
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
31
+ const request =
32
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
33
+ const url = new URL(request.url);
34
+ if (url.pathname.includes('/creds/')) {
35
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
36
+ return Response.json({ data, lease_duration: 0 });
37
+ }
38
+ if (request.method === 'GET') {
39
+ const body = await request.text();
40
+ // ⛔ THE REQUEST-SHAPE REGRESSION'S OWN SIGNATURE: a real fetch client refuses this outright.
41
+ expect(body).toBe('');
42
+ }
43
+ // ⚠️ `decodeURIComponent` — user.ts's own ⚠️: distilled percent-encodes a `{userid}`/
44
+ // `{groupid}` label substitution (`tim@pve` -> `tim%40pve`), which a literal `path` compare
45
+ // would silently never match, turning every case below into a no-op stub.
46
+ const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
47
+ if (wire !== path) return Response.json({ data: null });
48
+ if (transient) {
49
+ // MEASURED 2026-09-24, live TB4: a missing user/group answers exactly this shape.
50
+ return Response.json({ data: null, message: `no such object (${path})` }, { status: 500 });
51
+ }
52
+ return Response.json({ data: live });
53
+ };
54
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
55
+ return {
56
+ breakReads: () => {
57
+ transient = true;
58
+ },
59
+ layer: FetchHttpClient.layer.pipe(
60
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
61
+ ),
62
+ };
63
+ };
64
+
65
+ describe('a transient read failure fails the plan loudly, never a silent false update', () => {
66
+ test('Proxmox.User: diff() propagates a transient read failure once a state row exists', async () => {
67
+ // MEASURED 2026-09-24, live TB4 `GET /access/users/tim@pve`.
68
+ const live = {
69
+ comment: 'Primary admin — PVE fallback',
70
+ email: 'tim@schenanigans.com',
71
+ enable: 1,
72
+ expire: 0,
73
+ firstname: 'Timothy',
74
+ groups: ['Schenanigans'],
75
+ lastname: 'Schneider',
76
+ tokens: null,
77
+ };
78
+ const declare = () =>
79
+ ProxmoxUser('tim', {
80
+ comment: 'Primary admin — PVE fallback',
81
+ email: 'tim@schenanigans.com',
82
+ firstname: 'Timothy',
83
+ groups: ['Schenanigans'],
84
+ lastname: 'Schneider',
85
+ target: FAKE_TARGET,
86
+ userid: 'tim@pve',
87
+ });
88
+ const fake = clusterWithTransientFailure('access/users/tim@pve', live);
89
+ await withoutBao(async () => {
90
+ const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
91
+ // First deploy: adopts the matching account, records a state row.
92
+ expect(await engine.deploy(declare())).toEqual({ tim: 'adopted' });
93
+ // Now every read of this account fails the way the live regression measured.
94
+ fake.breakReads();
95
+ // ⛔ THE FIX: this must REJECT (fail the plan), never resolve with a false 'update'.
96
+ await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
97
+ });
98
+ });
99
+
100
+ test('Proxmox.Group: diff() propagates a transient read failure once a state row exists', async () => {
101
+ // MEASURED 2026-09-24, live TB4 `GET /access/groups/HomeAssistant`.
102
+ const live = { comment: 'Home Assistant service group', members: [] };
103
+ const declare = () =>
104
+ ProxmoxGroup('ha', {
105
+ comment: 'Home Assistant service group',
106
+ groupid: 'HomeAssistant',
107
+ target: FAKE_TARGET,
108
+ });
109
+ const fake = clusterWithTransientFailure('access/groups/HomeAssistant', live);
110
+ await withoutBao(async () => {
111
+ const engine = engineOver(ProxmoxGroupProvider().pipe(Layer.provideMerge(fake.layer)));
112
+ expect(await engine.deploy(declare())).toEqual({ ha: 'adopted' });
113
+ fake.breakReads();
114
+ await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
115
+ });
116
+ });
117
+
118
+ // ★ A brand-new declaration's own create-from-a-500 path is already covered end to end by
119
+ // user.test.ts's `missingAs500` fixture — `readUser`/`readGroup` (used by `reconcile`) kept
120
+ // their fold on purpose, exactly so that path stays unaffected by this fix.
121
+ });
122
+
123
+ describe('the same fix, one command over: alchemy drift never reports a transient failure as missing', () => {
124
+ // ⛔ FOUND BY ADVERSARIAL REVIEW OF THIS VERY FIX, 2026-09-24 — NOT part of the live-measured
125
+ // regression above, but the SAME bug class one layer deeper. `alchemy/src/Drift.ts` (`alchemy
126
+ // drift`/`sync`/`deploy --detect-drift`) calls a provider's `read` hook DIRECTLY on an
127
+ // already-confirmed row (`output: old.attr`, defined — Drift.ts:239-292), the same situation
128
+ // `diff` handles above. Before `user.ts`/`group.ts`'s `read` hook learned to branch on
129
+ // `output` (this fix's second half), it ALWAYS called the folding `readUser`/`readGroup` —
130
+ // so a transient failure here folded to `undefined`, and Drift.ts's own dry-run branch
131
+ // reports that as `{action: 'missing'}` with NO error surfaced at all: a silent false "this
132
+ // account is gone" report, worse than the false `update` `diff` used to produce because
133
+ // nothing here even logs a warning. `engineOver`'s new `drift` capability
134
+ // (verify/fake-engine.ts) drives the real `Alchemy.Drift.detect` over the same fake cluster.
135
+ test('Proxmox.User: a confirmed row propagates instead of reporting missing', async () => {
136
+ const live = {
137
+ comment: 'Primary admin — PVE fallback',
138
+ email: 'tim@schenanigans.com',
139
+ enable: 1,
140
+ expire: 0,
141
+ firstname: 'Timothy',
142
+ groups: ['Schenanigans'],
143
+ lastname: 'Schneider',
144
+ tokens: null,
145
+ };
146
+ const declare = () =>
147
+ ProxmoxUser('tim', {
148
+ comment: 'Primary admin — PVE fallback',
149
+ email: 'tim@schenanigans.com',
150
+ firstname: 'Timothy',
151
+ groups: ['Schenanigans'],
152
+ lastname: 'Schneider',
153
+ target: FAKE_TARGET,
154
+ userid: 'tim@pve',
155
+ });
156
+ const fake = clusterWithTransientFailure('access/users/tim@pve', live);
157
+ await withoutBao(async () => {
158
+ const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
159
+ expect(await engine.deploy(declare())).toEqual({ tim: 'adopted' });
160
+ fake.breakReads();
161
+ // ⛔ THE FIX: this must REJECT. Before `read`'s `output` branch, it resolved with
162
+ // `{ tim: { action: 'missing' } }` and no error anywhere.
163
+ await expect(engine.drift(declare())).rejects.toBeDefined();
164
+ });
165
+ });
166
+
167
+ test('Proxmox.Group: a confirmed row propagates instead of reporting missing', async () => {
168
+ const live = { comment: 'Home Assistant service group', members: [] };
169
+ const declare = () =>
170
+ ProxmoxGroup('ha', {
171
+ comment: 'Home Assistant service group',
172
+ groupid: 'HomeAssistant',
173
+ target: FAKE_TARGET,
174
+ });
175
+ const fake = clusterWithTransientFailure('access/groups/HomeAssistant', live);
176
+ await withoutBao(async () => {
177
+ const engine = engineOver(ProxmoxGroupProvider().pipe(Layer.provideMerge(fake.layer)));
178
+ expect(await engine.deploy(declare())).toEqual({ ha: 'adopted' });
179
+ fake.breakReads();
180
+ await expect(engine.drift(declare())).rejects.toBeDefined();
181
+ });
182
+ });
183
+ });
@@ -107,7 +107,15 @@ export interface ProxmoxRole extends Resource<
107
107
 
108
108
  export const ProxmoxRole = Resource<ProxmoxRole>('Proxmox.Role');
109
109
 
110
- /** ★ Default `read` role — see the header's ⚠️ on the list read's privilege requirement. */
110
+ /**
111
+ * ★ Default `read` role — see the header's ⚠️ on the list read's privilege requirement.
112
+ * ⛔ NO `orElseSucceed` — found 2026-09-24, the same bug class as the credential denial fix. A
113
+ * role's absence is ALWAYS a SUCCESSFUL read (the list comes back and the role is not in it —
114
+ * `access.listAccessRoles` never 404s/500s per-role the way `getAccessUser`/`getAccessGroup`
115
+ * do, MEASURED), so this family never needed a folding/non-folding split the way user.ts and
116
+ * group.ts do: any THROWN failure here is genuinely unexpected and must propagate and fail the
117
+ * whole plan loudly, in `diff`, `read` and `reconcile` alike — never fold to "absent → update".
118
+ */
111
119
  const readRole = (props: RoleProps) =>
112
120
  readOrUnreadable(runPve(props.target, 'read', false, access.listAccessRoles({}))).pipe(
113
121
  Effect.map((rows) => {
@@ -115,7 +123,6 @@ const readRole = (props: RoleProps) =>
115
123
  const row = find(rows, props.roleid);
116
124
  return row === undefined ? undefined : attributesOf(row, props);
117
125
  }),
118
- Effect.orElseSucceed(() => undefined),
119
126
  );
120
127
 
121
128
  /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `Proxmox.Storage`'s WRITE side: the locator bag, the create/update forms, and the translation
3
+ * into distilled's own typed request shape. Split out of storage-wire.ts (2026-09-24) to keep
4
+ * both files under the 250-line cap — the api-token.ts/api-token-form.ts seam, same reasoning.
5
+ */
6
+ import type * as storage from '@distilled.cloud/proxmox/storage';
7
+ import type { StorageProps } from './storage.ts';
8
+ import { flag } from './values.ts';
9
+
10
+ export const STORAGE_CREATE = 'pve:POST /storage';
11
+ export const STORAGE_UPDATE = 'pve:PUT /storage/{storage}';
12
+
13
+ /**
14
+ * The create-time, plugin-specific half of a storage: `dir` wants `path`, `nfs` `server`+`export`,
15
+ * `cifs` `server`+`share`, `pbs` `server`+`datastore`, `rbd` `pool`+`monhost`, `lvmthin`
16
+ * `vgname`+`thinpool`, `zfspool` `pool`. A bag rather than two dozen named fields, because PVE
17
+ * ships about that many plugins and each brings its own locator. ⚠️ Never diffed — see `matches`
18
+ * (storage-wire.ts).
19
+ *
20
+ * ⛔ `password`, `keyring` AND `encryption-key` ARE `never` ON PURPOSE — see the ⛔ in storage.ts's
21
+ * header. POSITIVE-CONTROLLED rather than assumed: all three are a compile error, `TS2322: Type
22
+ * 'string' is not assignable to type 'undefined'`.
23
+ * ⚠️ EVERY KEY IN THIS BAG GOES THROUGH `underscored` (below) BEFORE IT REACHES DISTILLED — see
24
+ * that function's own header. A caller writes PVE's own field names here (`fs-name`, matching
25
+ * `pvesm`/pve-docs), never distilled's renamed ones.
26
+ */
27
+ export type StorageLocator = Record<string, string> & {
28
+ password?: never;
29
+ keyring?: never;
30
+ 'encryption-key'?: never;
31
+ };
32
+
33
+ const field = (name: string, value: string | undefined): Record<string, string> =>
34
+ value === undefined ? {} : { [name]: value };
35
+
36
+ /**
37
+ * ⚠️ THE CAST DROPS THE THREE `never` GUARDS AND NOTHING ELSE, and it is load-bearing rather than
38
+ * lazy: an intersection carrying OPTIONAL properties is not assignable to `Record<string,string>`
39
+ * at all, so the guards and the form body cannot both exist without one conversion. At runtime a
40
+ * locator is a plain string map; the guards only ever existed to fail a declaration.
41
+ */
42
+ const locatorForm = (locator: StorageLocator | undefined) =>
43
+ ({ ...locator }) as Record<string, string>;
44
+
45
+ /**
46
+ * ★ ROUTES EVERY OUTGOING FIELD THROUGH DISTILLED'S OWN TYPED PROPERTY, RATHER THAN THROUGH ITS
47
+ * "UNKNOWN KEY" FALLBACK — NOT BECAUSE THE WIRE NEEDS THE UNDERSCORED SPELLING. MEASURED both
48
+ * ways (`storage-read-failure.test.ts`'s create test, run once with this function and once
49
+ * without it): PVE's own hyphenated name (`content-dirs=…`) reaches the wire either way —
50
+ * `@distilled.cloud/proxmox`'s generated `T.Body("content-dirs")` annotation re-hyphenates its
51
+ * underscored `content_dirs` property on the way out, AND an untranslated hyphenated key also
52
+ * passes through unchanged as an "unknown key", form-encoded alongside the recognized ones (the
53
+ * GET-request version of that same passthrough is what caused kit 0.31.1's regression — see
54
+ * user.ts/group.ts — but a POST/PUT is already body-bearing, so the failure mode there doesn't
55
+ * apply). So EITHER path is wire-correct for a plain rename. What `underscored` buys instead:
56
+ * distilled's own schema only runs its declared per-field logic (a `T.Body()` rename, but also
57
+ * whatever type coercion or list-joining a FUTURE plugin field might carry) for a property it
58
+ * recognizes — an unrecognized key gets none of that, silently. `StorageLocator` is free-form
59
+ * on purpose (the header's own reasoning), so this file cannot enumerate every field a plugin
60
+ * might need; routing through the recognized property is the one way to get distilled's own
61
+ * per-field handling for a field this package's author never tried, rather than a bare rename.
62
+ * Renamed against every hyphenated field on the generated `CreateStorageRequest`/
63
+ * `PutStorageRequest` today (`prune-backups` -> `prune_backups`, `fs-name` -> `fs_name`,
64
+ * `max-protected-backups` -> `max_protected_backups`, `zfs-base-path` -> `zfs_base_path`,
65
+ * `content-dirs` -> `content_dirs`, `saferemove-stepsize` -> `saferemove_stepsize`, and so on) —
66
+ * a generic transform, not a lookup table, for the same free-form reason. No other migrated
67
+ * family (acl/group/role/user/api-token) hit any of this: none of their fields are hyphenated.
68
+ * ⛔ NEVER APPLIED TO THE FORM `guardWrite` CHECKS, REGARDLESS. The generated vendor-constraint
69
+ * tables (`generated/constraints/pve-storage.ts`) are keyed by PVE's OWN hyphenated names —
70
+ * running this first would make every constraint on `prune-backups`, `fs-name`, etc. silently
71
+ * never match, the vendor check passing over a value it never actually inspected. `createForm`/
72
+ * `updateForm` stay hyphenated for exactly that reason; only `toDistilledCreate`/
73
+ * `toDistilledUpdate` (the actual SDK call, storage.ts's `reconcile`) apply this.
74
+ */
75
+ const underscored = (form: Record<string, string>): Record<string, string> =>
76
+ Object.fromEntries(Object.entries(form).map(([key, value]) => [key.replaceAll('-', '_'), value]));
77
+
78
+ /**
79
+ * The mutable half, for create and update alike.
80
+ *
81
+ * ⚠️ AN UNDECLARED FIELD IS NEITHER SENT NOR COMPARED: undeclared means UNMANAGED here, unlike
82
+ * `Proxmox.Pool` where an absent comment means `''`. A storage has no single default to fall back
83
+ * on — `content` defaults per plugin, and clearing a field needs an explicit `delete=` parameter
84
+ * rather than an empty value — so a guessed default would rewrite a storage somebody tuned by
85
+ * hand, and comparing one would report drift nobody declared.
86
+ *
87
+ * ⚠️ RECONCILE PUTs UNCONDITIONALLY whenever the object exists (group.ts's own reconcile has the
88
+ * same note), so this form must be safe to re-apply to a storage that already matches. It is:
89
+ * the declared set and nothing else, and PVE leaves absent parameters alone.
90
+ *
91
+ * ★ KEPT HYPHENATED (`'prune-backups'`, PVE's own wire name), NOT DISTILLED'S UNDERSCORED
92
+ * `prune_backups` — this form is what `guardWrite` checks (storage.ts), and the generated
93
+ * vendor-constraint table is keyed by PVE's own names (`underscored`'s own header has the
94
+ * measured evidence). `toDistilledCreate`/`toDistilledUpdate` translate a COPY for the actual
95
+ * SDK call; this function and its callers never see the underscored form.
96
+ */
97
+ const mutable = (props: StorageProps) => ({
98
+ ...field('comment', props.comment),
99
+ ...field('content', props.content),
100
+ ...field('disable', flag(props.disable)),
101
+ ...field('nodes', props.nodes),
102
+ ...field('preallocation', props.preallocation),
103
+ ...field('prune-backups', props['prune-backups']),
104
+ ...field('shared', flag(props.shared)),
105
+ });
106
+
107
+ /**
108
+ * ⚠️ THE BAG IS SPREAD FIRST SO NOTHING IN IT CAN SHADOW A FIELD THIS RESOURCE MANAGES. A stray
109
+ * `storage` or `type` in a locator would otherwise rename the object being created, and PVE
110
+ * would build the wrong thing under a name Alchemy then records as the declared one.
111
+ * ★ HYPHENATED, THE WIRE SHAPE — `guardWrite` (storage.ts) checks this form directly; see
112
+ * `underscored`'s header for why translating it here would blind that check.
113
+ */
114
+ export const createForm = (props: StorageProps): Record<string, string> => ({
115
+ ...locatorForm(props.locator),
116
+ ...mutable(props),
117
+ storage: props.storage,
118
+ type: props.type,
119
+ });
120
+
121
+ /**
122
+ * ⚠️ `type` AND THE LOCATOR ARE NEVER SENT ON UPDATE — PVE's PUT refuses the create-only fields
123
+ * (`PutStorageRequest` has no `type`, and the plugin locator fields it does carry are for a
124
+ * different purpose: moving an existing volume, not re-pointing the definition), and comparing
125
+ * either could only plan an update that no write can apply: a plan that reports work forever.
126
+ */
127
+ export const updateForm = (props: StorageProps): Record<string, string> => ({
128
+ ...mutable(props),
129
+ storage: props.storage,
130
+ });
131
+
132
+ /**
133
+ * The actual `createStorage` call body — `createForm`, translated once. See `underscored`.
134
+ * ⚠️ `as unknown as` — same load-bearing cast as `locatorForm`'s own ⚠️: a plain
135
+ * `Record<string,string>` does not structurally overlap a named-field interface enough for
136
+ * TypeScript to accept a direct cast, even though every field it carries fits one of the
137
+ * interface's own (all-optional-but-two) properties.
138
+ */
139
+ export const toDistilledCreate = (props: StorageProps): storage.CreateStorageRequest =>
140
+ underscored(createForm(props)) as unknown as storage.CreateStorageRequest;
141
+
142
+ /** The actual `putStorage` call body — `updateForm`, translated once. See `toDistilledCreate`. */
143
+ export const toDistilledUpdate = (props: StorageProps): storage.PutStorageRequest =>
144
+ underscored(updateForm(props)) as unknown as storage.PutStorageRequest;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * `Proxmox.Storage`'s own cries-wolf fix, PLUS the request-shape and transient-failure fixes
3
+ * kit 0.31.2 (PR 239) found for User/Group — all three apply here for the first time on a
4
+ * `provision`-role read, not a `read`-role one. Real-shaped fixtures (live TB4 response bodies,
5
+ * measured 2026-09-24, nothing secret): `GET /storage/local` -> `{"data":{"storage":"local",
6
+ * "type":"dir","content":"backup,vztmpl,iso","path":"/var/lib/vz","digest":"..."}}`, and a
7
+ * missing storage -> `{"message":"storage '...' does not exist\n"}` at HTTP 500, the same shape
8
+ * user.ts/group.ts measured for a missing user/group.
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import * as Layer from 'effect/Layer';
12
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
13
+ import { engineOver } from '../verify/fake-engine.ts';
14
+ import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
15
+ import { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
16
+
17
+ /**
18
+ * One storage row, an OpenBao mint the test can deny for `provision` mid-run (the cries-wolf
19
+ * case), and a cluster answer the test can break into a transient 500 mid-run (the request-shape
20
+ * regression's own case) — never both at once, so one fixture serves every test below.
21
+ */
22
+ const clusterOverStorage = (id: string, live: Record<string, unknown>) => {
23
+ let denyProvision = false;
24
+ let transient = false;
25
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
26
+ const request =
27
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
28
+ const url = new URL(request.url);
29
+ if (url.pathname.includes('/creds/')) {
30
+ if (denyProvision && url.pathname.endsWith('/creds/provision')) {
31
+ return Response.json({ errors: ['permission denied'] }, { status: 403 });
32
+ }
33
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
34
+ return Response.json({ data, lease_duration: 0 });
35
+ }
36
+ if (request.method === 'GET') {
37
+ // ⛔ THE REQUEST-SHAPE REGRESSION'S OWN SIGNATURE (user.ts/group.ts): a real fetch client
38
+ // refuses a GET carrying a body outright.
39
+ expect(await request.text()).toBe('');
40
+ }
41
+ const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
42
+ if (wire !== `storage/${id}`) return Response.json({ data: null });
43
+ if (transient) {
44
+ // MEASURED 2026-09-24, live TB4: a missing storage answers exactly this shape.
45
+ return Response.json(
46
+ { message: `storage '${id}' does not exist\n`, data: null },
47
+ { status: 500 },
48
+ );
49
+ }
50
+ return Response.json({ data: live });
51
+ };
52
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
53
+ return {
54
+ breakReads: () => {
55
+ transient = true;
56
+ },
57
+ denyProvision: () => {
58
+ denyProvision = true;
59
+ },
60
+ layer: FetchHttpClient.layer.pipe(
61
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
62
+ ),
63
+ };
64
+ };
65
+
66
+ // MEASURED 2026-09-24, live TB4 `GET /storage/local`.
67
+ const live = { content: 'backup,vztmpl,iso', storage: 'local', type: 'dir' };
68
+ const declare = () =>
69
+ ProxmoxStorage('local', {
70
+ content: 'backup,vztmpl,iso',
71
+ storage: 'local',
72
+ target: FAKE_TARGET,
73
+ type: 'dir',
74
+ });
75
+
76
+ describe("Proxmox.Storage's own cries-wolf fix: a refused provision mint reports noop, never update", () => {
77
+ test('an already-correct storage plans noop once the credential is denied, and nothing is written', async () => {
78
+ const fake = clusterOverStorage('local', live);
79
+ await withoutBao(async () => {
80
+ const engine = engineOver(ProxmoxStorageProvider().pipe(Layer.provideMerge(fake.layer)));
81
+ expect(await engine.deploy(declare())).toEqual({ local: 'adopted' });
82
+ fake.denyProvision();
83
+ const report = await engine.verify(declare(), { all: true });
84
+ // ⛔ THE BUG THIS PROVES FIXED — MEASURED live 2026-09-24: before this migration this row
85
+ // read `update` with nothing compared, the same as every other family before its fix.
86
+ expect(report.rows[0]).toMatchObject({ diff: 'noop' });
87
+ expect(await engine.deploy(declare())).toEqual({ local: 'noop' });
88
+ });
89
+ });
90
+ });
91
+
92
+ describe('the same request-shape and transient-failure fixes, once for Proxmox.Storage', () => {
93
+ test('diff() propagates a transient read failure once a state row exists', async () => {
94
+ const fake = clusterOverStorage('local', live);
95
+ await withoutBao(async () => {
96
+ const engine = engineOver(ProxmoxStorageProvider().pipe(Layer.provideMerge(fake.layer)));
97
+ expect(await engine.deploy(declare())).toEqual({ local: 'adopted' });
98
+ fake.breakReads();
99
+ // ⛔ THE FIX: this must REJECT, never resolve with a false 'update'.
100
+ await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
101
+ });
102
+ });
103
+
104
+ test('alchemy drift never reports a transient failure as missing', async () => {
105
+ const fake = clusterOverStorage('local', live);
106
+ await withoutBao(async () => {
107
+ const engine = engineOver(ProxmoxStorageProvider().pipe(Layer.provideMerge(fake.layer)));
108
+ expect(await engine.deploy(declare())).toEqual({ local: 'adopted' });
109
+ fake.breakReads();
110
+ // ⛔ THE FIX: this must REJECT. Without `read`'s `output` branch it resolved with
111
+ // `{ local: { action: 'missing' } }` and no error anywhere — user.ts's/group.ts's own gap.
112
+ await expect(engine.drift(declare())).rejects.toBeDefined();
113
+ });
114
+ });
115
+ });
116
+
117
+ /**
118
+ * A fake cluster for a storage that does NOT exist yet: every GET is a 500 ("does not exist",
119
+ * the measured shape) until `create()` fires, which records the POST body and starts answering
120
+ * `live` afterward — so `reconcile`'s `before === undefined` branch, untouched by every test
121
+ * above, actually runs.
122
+ */
123
+ const clusterCreatingStorage = (id: string, live: Record<string, unknown>) => {
124
+ let created = false;
125
+ const posted: string[] = [];
126
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
127
+ const request =
128
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
129
+ const url = new URL(request.url);
130
+ if (url.pathname.includes('/creds/')) {
131
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
132
+ return Response.json({ data, lease_duration: 0 });
133
+ }
134
+ const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
135
+ if (request.method === 'POST' && wire === 'storage') {
136
+ posted.push(await request.text());
137
+ created = true;
138
+ return Response.json({ data: null });
139
+ }
140
+ if (wire !== `storage/${id}`) return Response.json({ data: null });
141
+ if (!created) {
142
+ return Response.json(
143
+ { message: `storage '${id}' does not exist\n`, data: null },
144
+ { status: 500 },
145
+ );
146
+ }
147
+ return Response.json({ data: live });
148
+ };
149
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
150
+ return {
151
+ posted,
152
+ layer: FetchHttpClient.layer.pipe(
153
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
154
+ ),
155
+ };
156
+ };
157
+
158
+ describe('reconcile creating a brand-new storage — the before === undefined branch', () => {
159
+ // ⛔ FOUND BY ADVERSARIAL REVIEW OF THIS PR: every test above adopts an EXISTING storage, so
160
+ // `reconcile`'s create branch (`toDistilledCreate`, the hyphen -> underscore translation) was
161
+ // never actually exercised — "correct by reading the generated SDK source", not by a test.
162
+ test('translates a hyphenated locator field for the actual SDK call, not the vendor-check form', async () => {
163
+ const newLive = {
164
+ content: 'iso',
165
+ 'content-dirs': 'iso=/mnt/hf-test-new/iso',
166
+ storage: 'hf-test-new',
167
+ type: 'dir',
168
+ };
169
+ const fake = clusterCreatingStorage('hf-test-new', newLive);
170
+ await withoutBao(async () => {
171
+ const engine = engineOver(ProxmoxStorageProvider().pipe(Layer.provideMerge(fake.layer)));
172
+ const declareNew = () =>
173
+ ProxmoxStorage('newone', {
174
+ content: 'iso',
175
+ locator: { 'content-dirs': 'iso=/mnt/hf-test-new/iso', path: '/mnt/hf-test-new' },
176
+ storage: 'hf-test-new',
177
+ target: FAKE_TARGET,
178
+ type: 'dir',
179
+ });
180
+ expect(await engine.deploy(declareNew())).toEqual({ newone: 'create' });
181
+ expect(fake.posted).toHaveLength(1);
182
+ // ★ MEASURED, NOT ASSUMED: the wire itself carries PVE's own hyphenated `content-dirs=`
183
+ // either way — distilled's generated `T.Body("content-dirs")` annotation re-hyphenates
184
+ // its underscored `content_dirs` property on the way out, and (confirmed by temporarily
185
+ // removing `underscored()` and re-running this exact test) an untranslated hyphenated key
186
+ // also reaches the wire unchanged as a passthrough "unknown key". `underscored()` is not
187
+ // about THIS field's own wire spelling, then — it is about routing every locator field
188
+ // through distilled's typed, declared property (comment further up `toDistilledCreate`),
189
+ // rather than the weaker unknown-key fallback, for whatever OTHER per-field wire logic a
190
+ // future plugin field might carry beyond a plain rename (a type coercion, a list join) —
191
+ // which this test cannot rule out for every field distilled might ever add.
192
+ expect(fake.posted[0]).toContain('content-dirs=iso');
193
+ });
194
+ });
195
+
196
+ test('a vendor-constraint violation on a hyphenated field is refused before any write', async () => {
197
+ const fake = clusterCreatingStorage('hf-test-bad', {});
198
+ await withoutBao(async () => {
199
+ const engine = engineOver(ProxmoxStorageProvider().pipe(Layer.provideMerge(fake.layer)));
200
+ const declareBad = () =>
201
+ ProxmoxStorage('bad', {
202
+ // ⛔ `saferemove-stepsize` only allows 1/2/4/8/16/32 (generated/constraints/pve-storage.ts)
203
+ // — '99' must be refused by `guardWrite`, which proves it inspected the HYPHENATED
204
+ // key `createForm` builds, not the underscored one only the SDK call ever sees.
205
+ locator: { path: '/mnt/hf-test-bad', 'saferemove-stepsize': '99' },
206
+ storage: 'hf-test-bad',
207
+ target: FAKE_TARGET,
208
+ type: 'dir',
209
+ });
210
+ await expect(engine.deploy(declareBad())).rejects.toBeDefined();
211
+ expect(fake.posted).toHaveLength(0);
212
+ });
213
+ });
214
+ });