@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.
Files changed (184) hide show
  1. package/README.md +39 -3
  2. package/dist/grafana/dashboard-model.d.ts +77 -0
  3. package/dist/grafana/dashboard-model.d.ts.map +1 -0
  4. package/dist/grafana/dashboard.d.ts +98 -0
  5. package/dist/grafana/dashboard.d.ts.map +1 -0
  6. package/dist/grafana/folder.d.ts +100 -0
  7. package/dist/grafana/folder.d.ts.map +1 -0
  8. package/dist/grafana/index.d.ts +9 -8
  9. package/dist/grafana/index.d.ts.map +1 -1
  10. package/dist/grafana/index.js +158 -6
  11. package/dist/grafana/index.js.map +8 -4
  12. package/dist/grafana/providers.d.ts +1 -1
  13. package/dist/grafana/providers.d.ts.map +1 -1
  14. package/dist/grafana/provisioned.d.ts +11 -0
  15. package/dist/grafana/provisioned.d.ts.map +1 -0
  16. package/dist/linux/container-form.d.ts +129 -0
  17. package/dist/linux/container-form.d.ts.map +1 -0
  18. package/dist/linux/container-generator.d.ts +74 -0
  19. package/dist/linux/container-generator.d.ts.map +1 -0
  20. package/dist/linux/container-handlers.d.ts +15 -0
  21. package/dist/linux/container-handlers.d.ts.map +1 -0
  22. package/dist/linux/container-lifecycle.d.ts +37 -0
  23. package/dist/linux/container-lifecycle.d.ts.map +1 -0
  24. package/dist/linux/container-preflight.d.ts +34 -0
  25. package/dist/linux/container-preflight.d.ts.map +1 -0
  26. package/dist/linux/container-secrets.d.ts +37 -0
  27. package/dist/linux/container-secrets.d.ts.map +1 -0
  28. package/dist/linux/container-settle.d.ts +34 -0
  29. package/dist/linux/container-settle.d.ts.map +1 -0
  30. package/dist/linux/container.d.ts +30 -0
  31. package/dist/linux/container.d.ts.map +1 -0
  32. package/dist/linux/fake-quadlet.d.ts +25 -0
  33. package/dist/linux/fake-quadlet.d.ts.map +1 -0
  34. package/dist/linux/index.d.ts +6 -3
  35. package/dist/linux/index.d.ts.map +1 -1
  36. package/dist/linux/index.js +641 -268
  37. package/dist/linux/index.js.map +15 -7
  38. package/dist/linux/providers.d.ts +1 -1
  39. package/dist/linux/providers.d.ts.map +1 -1
  40. package/dist/linux/systemctl.d.ts +7 -1
  41. package/dist/linux/systemctl.d.ts.map +1 -1
  42. package/dist/opnsense/alias-form.d.ts +29 -0
  43. package/dist/opnsense/alias-form.d.ts.map +1 -0
  44. package/dist/opnsense/alias.d.ts +81 -0
  45. package/dist/opnsense/alias.d.ts.map +1 -0
  46. package/dist/opnsense/category-form.d.ts +10 -0
  47. package/dist/opnsense/category-form.d.ts.map +1 -0
  48. package/dist/opnsense/category.d.ts +58 -0
  49. package/dist/opnsense/category.d.ts.map +1 -0
  50. package/dist/opnsense/credentials.d.ts +36 -0
  51. package/dist/opnsense/credentials.d.ts.map +1 -0
  52. package/dist/opnsense/fake-opnsense.d.ts +51 -0
  53. package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
  54. package/dist/opnsense/group-form.d.ts +11 -0
  55. package/dist/opnsense/group-form.d.ts.map +1 -0
  56. package/dist/opnsense/group.d.ts +62 -0
  57. package/dist/opnsense/group.d.ts.map +1 -0
  58. package/dist/opnsense/index.d.ts +16 -0
  59. package/dist/opnsense/index.d.ts.map +1 -0
  60. package/dist/opnsense/index.js +211 -0
  61. package/dist/opnsense/index.js.map +20 -0
  62. package/dist/opnsense/policy.d.ts +20 -0
  63. package/dist/opnsense/policy.d.ts.map +1 -0
  64. package/dist/opnsense/providers.d.ts +13 -0
  65. package/dist/opnsense/providers.d.ts.map +1 -0
  66. package/dist/opnsense/resource.d.ts +74 -0
  67. package/dist/opnsense/resource.d.ts.map +1 -0
  68. package/dist/opnsense/wire.d.ts +39 -0
  69. package/dist/opnsense/wire.d.ts.map +1 -0
  70. package/dist/proxmox/index.js +170 -80
  71. package/dist/proxmox/index.js.map +9 -7
  72. package/dist/proxmox/storage-form.d.ts +54 -0
  73. package/dist/proxmox/storage-form.d.ts.map +1 -0
  74. package/dist/proxmox/storage-wire.d.ts +56 -0
  75. package/dist/proxmox/storage-wire.d.ts.map +1 -0
  76. package/dist/proxmox/storage.d.ts +55 -60
  77. package/dist/proxmox/storage.d.ts.map +1 -1
  78. package/dist/proxmox/zfs-pool-form.d.ts +124 -0
  79. package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
  80. package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
  81. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
  82. package/dist/proxmox/zfs-pool.d.ts +27 -52
  83. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  84. package/dist/unifi/fake-unifi.d.ts +48 -0
  85. package/dist/unifi/fake-unifi.d.ts.map +1 -0
  86. package/dist/unifi/firewall-zone-form.d.ts +40 -0
  87. package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
  88. package/dist/unifi/firewall-zone.d.ts +50 -0
  89. package/dist/unifi/firewall-zone.d.ts.map +1 -0
  90. package/dist/unifi/index.d.ts +13 -0
  91. package/dist/unifi/index.d.ts.map +1 -0
  92. package/dist/unifi/index.js +190 -0
  93. package/dist/unifi/index.js.map +16 -0
  94. package/dist/unifi/network-form.d.ts +67 -0
  95. package/dist/unifi/network-form.d.ts.map +1 -0
  96. package/dist/unifi/network.d.ts +67 -0
  97. package/dist/unifi/network.d.ts.map +1 -0
  98. package/dist/unifi/policy.d.ts +20 -0
  99. package/dist/unifi/policy.d.ts.map +1 -0
  100. package/dist/unifi/providers.d.ts +13 -0
  101. package/dist/unifi/providers.d.ts.map +1 -0
  102. package/dist/unifi/resource.d.ts +64 -0
  103. package/dist/unifi/resource.d.ts.map +1 -0
  104. package/docs/grafana-folder-dashboard.md +145 -0
  105. package/docs/grafana.md +11 -10
  106. package/docs/opnsense.md +146 -0
  107. package/docs/provider-standard.md +1 -1
  108. package/docs/quadlet-container.md +132 -0
  109. package/docs/unifi.md +93 -0
  110. package/docs/upstream-conformance.md +9 -4
  111. package/package.json +12 -2
  112. package/src/grafana/dashboard-model.test.ts +111 -0
  113. package/src/grafana/dashboard-model.ts +111 -0
  114. package/src/grafana/dashboard.test.ts +232 -0
  115. package/src/grafana/dashboard.ts +161 -0
  116. package/src/grafana/folder.test.ts +212 -0
  117. package/src/grafana/folder.ts +161 -0
  118. package/src/grafana/index.ts +21 -8
  119. package/src/grafana/providers.ts +7 -1
  120. package/src/grafana/provisioned.ts +49 -0
  121. package/src/linux/container-fixture.test.ts +92 -0
  122. package/src/linux/container-form.ts +243 -0
  123. package/src/linux/container-generator.test.ts +195 -0
  124. package/src/linux/container-generator.ts +113 -0
  125. package/src/linux/container-handlers.ts +73 -0
  126. package/src/linux/container-lifecycle.test.ts +209 -0
  127. package/src/linux/container-lifecycle.ts +217 -0
  128. package/src/linux/container-preflight.ts +97 -0
  129. package/src/linux/container-secrets.test.ts +171 -0
  130. package/src/linux/container-secrets.ts +112 -0
  131. package/src/linux/container-settle.ts +97 -0
  132. package/src/linux/container.ts +60 -0
  133. package/src/linux/fake-quadlet.ts +127 -0
  134. package/src/linux/index.ts +11 -3
  135. package/src/linux/providers.ts +2 -0
  136. package/src/linux/systemctl.ts +9 -0
  137. package/src/opnsense/alias-form.test.ts +89 -0
  138. package/src/opnsense/alias-form.ts +53 -0
  139. package/src/opnsense/alias.test.ts +203 -0
  140. package/src/opnsense/alias.ts +117 -0
  141. package/src/opnsense/category-form.test.ts +54 -0
  142. package/src/opnsense/category-form.ts +20 -0
  143. package/src/opnsense/category.test.ts +138 -0
  144. package/src/opnsense/category.ts +82 -0
  145. package/src/opnsense/credentials.ts +41 -0
  146. package/src/opnsense/fake-opnsense.ts +79 -0
  147. package/src/opnsense/group-form.test.ts +75 -0
  148. package/src/opnsense/group-form.ts +25 -0
  149. package/src/opnsense/group.test.ts +141 -0
  150. package/src/opnsense/group.ts +78 -0
  151. package/src/opnsense/index.ts +45 -0
  152. package/src/opnsense/policy.ts +49 -0
  153. package/src/opnsense/providers.ts +21 -0
  154. package/src/opnsense/resource.ts +146 -0
  155. package/src/opnsense/wire.ts +56 -0
  156. package/src/opnsense/write-refusal.test.ts +148 -0
  157. package/src/proxmox/constraints-host-forms.test.ts +1 -1
  158. package/src/proxmox/storage-form.ts +144 -0
  159. package/src/proxmox/storage-read-failure.test.ts +214 -0
  160. package/src/proxmox/storage-wire.ts +124 -0
  161. package/src/proxmox/storage.ts +165 -175
  162. package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
  163. package/src/proxmox/zfs-pool-form.ts +236 -0
  164. package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
  165. package/src/proxmox/zfs-pool-wire.ts +132 -0
  166. package/src/proxmox/zfs-pool.ts +106 -76
  167. package/src/unifi/fake-unifi.ts +60 -0
  168. package/src/unifi/firewall-zone-form.ts +65 -0
  169. package/src/unifi/firewall-zone.test.ts +88 -0
  170. package/src/unifi/firewall-zone.ts +66 -0
  171. package/src/unifi/index.ts +26 -0
  172. package/src/unifi/network-form.ts +207 -0
  173. package/src/unifi/network.test.ts +209 -0
  174. package/src/unifi/network.ts +83 -0
  175. package/src/unifi/policy.ts +37 -0
  176. package/src/unifi/providers.ts +15 -0
  177. package/src/unifi/resource.test.ts +133 -0
  178. package/src/unifi/resource.ts +131 -0
  179. package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
  180. package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
  181. package/dist/proxmox/zfs-pool-write.d.ts +0 -128
  182. package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
  183. package/src/proxmox/zfs-pool-spec.ts +0 -76
  184. package/src/proxmox/zfs-pool-write.ts +0 -196
@@ -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
+ });
@@ -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);