@homeflare/alchemy 0.39.0 → 0.41.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 (279) hide show
  1. package/README.md +10 -8
  2. package/dist/caddy/index.js.map +2 -2
  3. package/dist/ceph/auth-entity.d.ts +34 -0
  4. package/dist/ceph/auth-entity.d.ts.map +1 -0
  5. package/dist/ceph/ceph-argv-config.d.ts +10 -0
  6. package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
  7. package/dist/ceph/ceph-argv.d.ts +19 -0
  8. package/dist/ceph/ceph-argv.d.ts.map +1 -0
  9. package/dist/ceph/ceph-auth-form.d.ts +54 -0
  10. package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
  11. package/dist/ceph/ceph-auth-parse.d.ts +56 -0
  12. package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
  13. package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
  14. package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
  15. package/dist/ceph/ceph-config-options.d.ts +23 -0
  16. package/dist/ceph/ceph-config-options.d.ts.map +1 -0
  17. package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
  18. package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
  19. package/dist/ceph/ceph-transport.d.ts +37 -0
  20. package/dist/ceph/ceph-transport.d.ts.map +1 -0
  21. package/dist/ceph/fake-ceph-dial.d.ts +35 -0
  22. package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
  23. package/dist/ceph/index.d.ts +13 -0
  24. package/dist/ceph/index.d.ts.map +1 -0
  25. package/dist/ceph/index.js +1610 -0
  26. package/dist/ceph/index.js.map +38 -0
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/launchd/fake-runner.d.ts.map +1 -1
  30. package/dist/launchd/index.d.ts +1 -1
  31. package/dist/launchd/index.d.ts.map +1 -1
  32. package/dist/launchd/index.js +7 -1
  33. package/dist/launchd/index.js.map +5 -5
  34. package/dist/launchd/local-runner.d.ts.map +1 -1
  35. package/dist/launchd/runner.d.ts +21 -0
  36. package/dist/launchd/runner.d.ts.map +1 -1
  37. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  38. package/dist/linux/directory-lifecycle.d.ts.map +1 -1
  39. package/dist/linux/fake-linux-host.d.ts.map +1 -1
  40. package/dist/linux/index.d.ts +1 -1
  41. package/dist/linux/index.d.ts.map +1 -1
  42. package/dist/linux/index.js +16 -4
  43. package/dist/linux/index.js.map +7 -7
  44. package/dist/linux/ssh-runner.d.ts.map +1 -1
  45. package/dist/linux/sudo-runner.d.ts.map +1 -1
  46. package/dist/opnsense/alias-form.d.ts +14 -1
  47. package/dist/opnsense/alias-form.d.ts.map +1 -1
  48. package/dist/opnsense/alias.d.ts +1 -1
  49. package/dist/opnsense/alias.d.ts.map +1 -1
  50. package/dist/opnsense/group-form.d.ts +8 -2
  51. package/dist/opnsense/group-form.d.ts.map +1 -1
  52. package/dist/opnsense/group.d.ts +1 -1
  53. package/dist/opnsense/group.d.ts.map +1 -1
  54. package/dist/opnsense/index.js +15 -7
  55. package/dist/opnsense/index.js.map +7 -7
  56. package/dist/opnsense/wire.d.ts +42 -12
  57. package/dist/opnsense/wire.d.ts.map +1 -1
  58. package/dist/proxmox/index.js +62 -39
  59. package/dist/proxmox/index.js.map +8 -7
  60. package/dist/proxmox/qemu-volume.d.ts +12 -2
  61. package/dist/proxmox/qemu-volume.d.ts.map +1 -1
  62. package/dist/proxmox/storage-form.d.ts +28 -3
  63. package/dist/proxmox/storage-form.d.ts.map +1 -1
  64. package/dist/proxmox/storage-plugin-options.d.ts +2 -0
  65. package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
  66. package/dist/proxmox/storage-wire.d.ts +28 -1
  67. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  68. package/dist/proxmox/storage.d.ts +6 -1
  69. package/dist/proxmox/storage.d.ts.map +1 -1
  70. package/dist/release/index.d.ts +1 -1
  71. package/dist/release/index.d.ts.map +1 -1
  72. package/dist/release/index.js.map +2 -2
  73. package/dist/talos/credentials-write.d.ts +46 -0
  74. package/dist/talos/credentials-write.d.ts.map +1 -0
  75. package/dist/talos/credentials.d.ts +51 -9
  76. package/dist/talos/credentials.d.ts.map +1 -1
  77. package/dist/talos/fake-process.d.ts +56 -0
  78. package/dist/talos/fake-process.d.ts.map +1 -0
  79. package/dist/talos/index.d.ts +19 -4
  80. package/dist/talos/index.d.ts.map +1 -1
  81. package/dist/talos/index.js +357 -173
  82. package/dist/talos/index.js.map +16 -10
  83. package/dist/talos/kubeconfig-attrs.d.ts +4 -0
  84. package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
  85. package/dist/talos/kubeconfig.d.ts +41 -23
  86. package/dist/talos/kubeconfig.d.ts.map +1 -1
  87. package/dist/talos/machine-config-fixtures.d.ts +60 -0
  88. package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
  89. package/dist/talos/machine-config-poll.d.ts +43 -0
  90. package/dist/talos/machine-config-poll.d.ts.map +1 -0
  91. package/dist/talos/machine-config-read.d.ts +49 -0
  92. package/dist/talos/machine-config-read.d.ts.map +1 -0
  93. package/dist/talos/resource.d.ts +18 -0
  94. package/dist/talos/resource.d.ts.map +1 -1
  95. package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
  96. package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
  97. package/dist/talos/talos-bootstrap.d.ts +87 -0
  98. package/dist/talos/talos-bootstrap.d.ts.map +1 -1
  99. package/dist/talos/talos-cluster-health.d.ts +43 -1
  100. package/dist/talos/talos-cluster-health.d.ts.map +1 -1
  101. package/dist/talos/talos-errors.d.ts +68 -0
  102. package/dist/talos/talos-errors.d.ts.map +1 -0
  103. package/dist/talos/talos-machine-config.d.ts +51 -15
  104. package/dist/talos/talos-machine-config.d.ts.map +1 -1
  105. package/dist/talos/talosctl.d.ts +7 -0
  106. package/dist/talos/talosctl.d.ts.map +1 -1
  107. package/dist/talos/values.d.ts +23 -3
  108. package/dist/talos/values.d.ts.map +1 -1
  109. package/dist/unifi/acl-rule-drift.d.ts +22 -0
  110. package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
  111. package/dist/unifi/acl-rule-form.d.ts +90 -0
  112. package/dist/unifi/acl-rule-form.d.ts.map +1 -0
  113. package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
  114. package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
  115. package/dist/unifi/acl-rule-ordering.d.ts +54 -0
  116. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
  117. package/dist/unifi/acl-rule.d.ts +49 -0
  118. package/dist/unifi/acl-rule.d.ts.map +1 -0
  119. package/dist/unifi/dns-policy-drift.d.ts +21 -0
  120. package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
  121. package/dist/unifi/dns-policy-form.d.ts +97 -0
  122. package/dist/unifi/dns-policy-form.d.ts.map +1 -0
  123. package/dist/unifi/dns-policy.d.ts +55 -0
  124. package/dist/unifi/dns-policy.d.ts.map +1 -0
  125. package/dist/unifi/firewall-policy-drift.d.ts +26 -0
  126. package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
  127. package/dist/unifi/firewall-policy-form.d.ts +102 -0
  128. package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
  129. package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
  130. package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
  131. package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
  132. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
  133. package/dist/unifi/firewall-policy.d.ts +57 -0
  134. package/dist/unifi/firewall-policy.d.ts.map +1 -0
  135. package/dist/unifi/index.d.ts +7 -0
  136. package/dist/unifi/index.d.ts.map +1 -1
  137. package/dist/unifi/index.js +588 -3
  138. package/dist/unifi/index.js.map +24 -4
  139. package/dist/unifi/paginate.d.ts +0 -12
  140. package/dist/unifi/paginate.d.ts.map +1 -1
  141. package/dist/unifi/providers.d.ts +1 -1
  142. package/dist/unifi/providers.d.ts.map +1 -1
  143. package/dist/unifi/scan-source.d.ts +11 -0
  144. package/dist/unifi/scan-source.d.ts.map +1 -0
  145. package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
  146. package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
  147. package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
  148. package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
  149. package/dist/unifi/traffic-matching-list.d.ts +51 -0
  150. package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
  151. package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
  152. package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
  153. package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
  154. package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
  155. package/dist/unifi/wifi-broadcast.d.ts +73 -0
  156. package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
  157. package/docs/distilled-interim.md +7 -0
  158. package/docs/launchd.md +5 -3
  159. package/docs/provider-standard.md +5 -1
  160. package/docs/talos-argocd-dogfood.md +160 -0
  161. package/docs/unifi-acl-rule.md +122 -0
  162. package/docs/unifi-dns-policy.md +75 -0
  163. package/docs/unifi-firewall-policy.md +109 -0
  164. package/docs/unifi-traffic-matching-list.md +82 -0
  165. package/docs/unifi-wifi.md +120 -0
  166. package/docs/unifi.md +10 -5
  167. package/package.json +7 -3
  168. package/src/ceph/auth-entity.ts +74 -0
  169. package/src/ceph/ceph-argv-config.ts +78 -0
  170. package/src/ceph/ceph-argv.test.ts +226 -0
  171. package/src/ceph/ceph-argv.ts +189 -0
  172. package/src/ceph/ceph-auth-form.test.ts +103 -0
  173. package/src/ceph/ceph-auth-form.ts +107 -0
  174. package/src/ceph/ceph-auth-parse.test.ts +155 -0
  175. package/src/ceph/ceph-auth-parse.ts +121 -0
  176. package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
  177. package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
  178. package/src/ceph/ceph-auth-reconcile.ts +119 -0
  179. package/src/ceph/ceph-config-options.test.ts +44 -0
  180. package/src/ceph/ceph-config-options.ts +36 -0
  181. package/src/ceph/ceph-key-leak-assert.ts +35 -0
  182. package/src/ceph/ceph-no-output-logging.test.ts +52 -0
  183. package/src/ceph/ceph-transport.test.ts +125 -0
  184. package/src/ceph/ceph-transport.ts +117 -0
  185. package/src/ceph/fake-ceph-dial.ts +55 -0
  186. package/src/ceph/index.ts +12 -0
  187. package/src/index.ts +1 -0
  188. package/src/launchd/fake-runner.ts +2 -0
  189. package/src/launchd/index.ts +8 -1
  190. package/src/launchd/local-runner.ts +16 -1
  191. package/src/launchd/runner.ts +22 -0
  192. package/src/launchd/sudo-runner.ts +3 -0
  193. package/src/linux/directory-lifecycle.ts +18 -3
  194. package/src/linux/directory-sudo.test.ts +59 -0
  195. package/src/linux/directory.test.ts +34 -8
  196. package/src/linux/fake-linux-host.ts +2 -0
  197. package/src/linux/index.ts +1 -0
  198. package/src/linux/ssh-runner.ts +4 -0
  199. package/src/linux/sudo-runner.ts +3 -0
  200. package/src/opnsense/alias-form.test.ts +24 -12
  201. package/src/opnsense/alias-form.ts +20 -7
  202. package/src/opnsense/alias.test.ts +13 -6
  203. package/src/opnsense/alias.ts +16 -10
  204. package/src/opnsense/group-form.test.ts +12 -8
  205. package/src/opnsense/group-form.ts +13 -4
  206. package/src/opnsense/group.test.ts +18 -7
  207. package/src/opnsense/group.ts +10 -3
  208. package/src/opnsense/wire.ts +57 -12
  209. package/src/proxmox/qemu-volume.test.ts +153 -4
  210. package/src/proxmox/qemu-volume.ts +71 -14
  211. package/src/proxmox/storage-form.ts +48 -8
  212. package/src/proxmox/storage-plugin-options.ts +45 -0
  213. package/src/proxmox/storage-update.test.ts +183 -0
  214. package/src/proxmox/storage-wire.ts +54 -7
  215. package/src/proxmox/storage.ts +18 -6
  216. package/src/release/index.ts +1 -0
  217. package/src/talos/credentials-write.test.ts +81 -0
  218. package/src/talos/credentials-write.ts +95 -0
  219. package/src/talos/credentials.test.ts +157 -0
  220. package/src/talos/credentials.ts +160 -90
  221. package/src/talos/fake-process.ts +94 -0
  222. package/src/talos/index.ts +39 -4
  223. package/src/talos/kubeconfig-attrs.ts +55 -0
  224. package/src/talos/kubeconfig.test.ts +256 -0
  225. package/src/talos/kubeconfig.ts +170 -90
  226. package/src/talos/machine-config-fixtures.ts +86 -0
  227. package/src/talos/machine-config-poll.test.ts +57 -0
  228. package/src/talos/machine-config-poll.ts +82 -0
  229. package/src/talos/machine-config-read.ts +118 -0
  230. package/src/talos/resource.ts +19 -0
  231. package/src/talos/secrets-gate.test.ts +63 -0
  232. package/src/talos/talos-bootstrap-etcd.ts +71 -0
  233. package/src/talos/talos-bootstrap.test.ts +218 -0
  234. package/src/talos/talos-bootstrap.ts +151 -45
  235. package/src/talos/talos-cluster-health.test.ts +143 -0
  236. package/src/talos/talos-cluster-health.ts +91 -28
  237. package/src/talos/talos-errors.ts +85 -0
  238. package/src/talos/talos-machine-config.test.ts +189 -0
  239. package/src/talos/talos-machine-config.ts +102 -88
  240. package/src/talos/talosctl.ts +8 -0
  241. package/src/talos/values.test.ts +34 -6
  242. package/src/talos/values.ts +41 -7
  243. package/src/unifi/acl-rule-drift.ts +57 -0
  244. package/src/unifi/acl-rule-form.ts +135 -0
  245. package/src/unifi/acl-rule-ordering-form.ts +79 -0
  246. package/src/unifi/acl-rule-ordering.test.ts +141 -0
  247. package/src/unifi/acl-rule-ordering.ts +70 -0
  248. package/src/unifi/acl-rule.test.ts +183 -0
  249. package/src/unifi/acl-rule.ts +61 -0
  250. package/src/unifi/discriminated-filter-decode.test.ts +134 -0
  251. package/src/unifi/dns-policy-drift.ts +48 -0
  252. package/src/unifi/dns-policy-form.ts +142 -0
  253. package/src/unifi/dns-policy.test.ts +154 -0
  254. package/src/unifi/dns-policy.ts +67 -0
  255. package/src/unifi/firewall-policy-drift.test.ts +151 -0
  256. package/src/unifi/firewall-policy-drift.ts +55 -0
  257. package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
  258. package/src/unifi/firewall-policy-form.ts +146 -0
  259. package/src/unifi/firewall-policy-ordering-form.ts +111 -0
  260. package/src/unifi/firewall-policy-ordering.test.ts +185 -0
  261. package/src/unifi/firewall-policy-ordering.ts +87 -0
  262. package/src/unifi/firewall-policy.test.ts +183 -0
  263. package/src/unifi/firewall-policy.ts +73 -0
  264. package/src/unifi/index.ts +63 -0
  265. package/src/unifi/paginate.test.ts +24 -0
  266. package/src/unifi/paginate.ts +15 -3
  267. package/src/unifi/providers.ts +19 -1
  268. package/src/unifi/scan-source.ts +64 -0
  269. package/src/unifi/traffic-matching-list-drift.ts +40 -0
  270. package/src/unifi/traffic-matching-list-form.ts +77 -0
  271. package/src/unifi/traffic-matching-list.test.ts +202 -0
  272. package/src/unifi/traffic-matching-list.ts +78 -0
  273. package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
  274. package/src/unifi/wifi-broadcast-drift.ts +69 -0
  275. package/src/unifi/wifi-broadcast-form.ts +211 -0
  276. package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
  277. package/src/unifi/wifi-broadcast.test.ts +155 -0
  278. package/src/unifi/wifi-broadcast.ts +90 -0
  279. package/src/unifi/write-op-reference.test.ts +8 -54
@@ -12,6 +12,7 @@ import { spec } from './group.ts';
12
12
  import { fakeFailure, fakeOpnsense, fakeOpnsenseLayer, getOnlyOrFail } from './fake-opnsense.ts';
13
13
  import type { OpnsenseWriteRefused } from './policy.ts';
14
14
  import { opnsenseOperations } from './resource.ts';
15
+ import { optionMap } from './wire.ts';
15
16
 
16
17
  const asRefusal = (value: unknown) => value as OpnsenseWriteRefused;
17
18
 
@@ -24,14 +25,18 @@ const PROPS: GroupProps = {
24
25
  uuid: UUID,
25
26
  };
26
27
 
27
- const liveItem = (overrides: Partial<group.GroupItem> = {}): group.GroupItem => ({
28
+ // `liveItem` builds a `ModelIfgroupentryReadItem` — the WHOLE-MODEL `get()`'s read-shaped item,
29
+ // whose `members` decodes as an option map, not a comma string (OPNSENSE-2 — see group-form.ts).
30
+ const liveItem = (
31
+ overrides: Partial<group.ModelIfgroupentryReadItem> = {},
32
+ ): group.ModelIfgroupentryReadItem => ({
28
33
  ifname: 'IOT_DEVICES',
29
- members: 'opt1,opt2',
34
+ members: optionMap('opt1', 'opt2'),
30
35
  sequence: '1',
31
36
  ...overrides,
32
37
  });
33
38
 
34
- const getResponse = (items: Record<string, group.GroupItem>) =>
39
+ const getResponse = (items: Record<string, group.ModelIfgroupentryReadItem>) =>
35
40
  Response.json({ group: { ifgroupentry: items } });
36
41
 
37
42
  describe('Opnsense.Firewall.Group spec.fetchLive', () => {
@@ -86,17 +91,21 @@ describe('opnsenseOperations(spec).read — S7/S8, marker-less API', () => {
86
91
 
87
92
  describe('opnsenseOperations(spec).diff', () => {
88
93
  test('noop when the declared member set matches live, regardless of order', async () => {
89
- const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem({ members: 'opt2,opt1' }) }));
94
+ const fake = getOnlyOrFail(() =>
95
+ getResponse({ [UUID]: liveItem({ members: optionMap('opt2', 'opt1') }) }),
96
+ );
90
97
  const result = await Effect.runPromise(
91
98
  opnsenseOperations(spec)
92
- .diff(PROPS, attributesOf(UUID, liveItem({ members: 'opt2,opt1' })))
99
+ .diff(PROPS, attributesOf(UUID, liveItem({ members: optionMap('opt2', 'opt1') })))
93
100
  .pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
94
101
  );
95
102
  expect(result).toEqual({ action: 'noop' });
96
103
  });
97
104
 
98
105
  test('update when a member was removed live', async () => {
99
- const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem({ members: 'opt1' }) }));
106
+ const fake = getOnlyOrFail(() =>
107
+ getResponse({ [UUID]: liveItem({ members: optionMap('opt1') }) }),
108
+ );
100
109
  const result = await Effect.runPromise(
101
110
  opnsenseOperations(spec)
102
111
  .diff(PROPS, attributesOf(UUID, liveItem()))
@@ -108,7 +117,9 @@ describe('opnsenseOperations(spec).diff', () => {
108
117
 
109
118
  describe('opnsenseOperations(spec).reconcile and destroy — the read-only policy', () => {
110
119
  test('reconcile observes with one GET, then refuses — never a write', async () => {
111
- const fake = getOnlyOrFail(() => getResponse({ [UUID]: liveItem({ members: 'opt1' }) }));
120
+ const fake = getOnlyOrFail(() =>
121
+ getResponse({ [UUID]: liveItem({ members: optionMap('opt1') }) }),
122
+ );
112
123
  const failure = asRefusal(
113
124
  await Effect.runPromise(
114
125
  Effect.flip(
@@ -2,8 +2,10 @@
2
2
  * `Opnsense.Firewall.Group` — a read-only declaration of one `firewall/group` item (an
3
3
  * "Interface Group", not a permission group) on the edge. READ-ONLY BY DESIGN — see policy.ts's
4
4
  * header. `reconcile`/`delete` always refuse. Mirrors alias.ts exactly; see that file's header
5
- * for why `get()` (whole model) is the read source rather than `getGroup` — the same generation
6
- * gap (no `uuid` in `GetGroupRequest`'s schema) applies here too.
5
+ * for why `get()` (whole model) is the read source uniformly across this family rather than the
6
+ * now-correct `getGroup` per-item operation (OPNSENSE-1 fixed `GetGroupRequest`'s missing `uuid`,
7
+ * but switching to it is a separate, later PR). `members` decodes as an option map, not a comma
8
+ * string — see `group-form.ts`'s `attributesOf` (OPNSENSE-2).
7
9
  */
8
10
  import { adopt } from 'alchemy/AdoptPolicy';
9
11
  import { Resource } from 'alchemy';
@@ -54,7 +56,12 @@ export const isOpnsenseFirewallGroup = (value: unknown): value is OpnsenseFirewa
54
56
  value !== null &&
55
57
  (value as { Type?: unknown }).Type === 'Opnsense.Firewall.Group';
56
58
 
57
- export const spec: OpnsenseSpec<GroupProps, Group.GroupItem, GroupAttributes, OpnsenseOpError> = {
59
+ export const spec: OpnsenseSpec<
60
+ GroupProps,
61
+ Group.ModelIfgroupentryReadItem,
62
+ GroupAttributes,
63
+ OpnsenseOpError
64
+ > = {
58
65
  attributes: attributesOf,
59
66
  fetchLive: (props) =>
60
67
  Group.get({}).pipe(Effect.map((res) => res.group?.ifgroupentry?.[props.uuid])),
@@ -2,20 +2,27 @@
2
2
  * How OPNsense's MVC model spells a value on the wire, and how to get it back to something
3
3
  * comparable — the `../proxmox/values.ts` idea, scoped to this vendor's own spellings.
4
4
  *
5
- * ★ OPNSENSE HAS EXACTLY TWO WIRE SHAPES THIS FAMILY NEEDS. Every `BooleanField` is the string
6
- * `"1"` or `"0"` (never a JSON boolean — `AliasItem.enabled: string`, `GroupItem.nogroup?:
7
- * string`, see `firewall_alias.ts`/`firewall_group.ts`). Every multi-select `OptionField`/
8
- * `ModelRelationField` is a SINGLE comma-joined string, not a JSON array (`AliasItem.categories`,
9
- * `GroupItem.members` — both carry the doc comment "wire value is a single comma-joined string,
10
- * not a JSON array" in the generated SDK). Nothing here is specific to one resource.
5
+ * ★ OPNSENSE HAS EXACTLY TWO WIRE SHAPES THIS FAMILY NEEDS ON WRITE (props stay plain values;
6
+ * this family never writes, but `matches` compares against them). Every `BooleanField` is the
7
+ * string `"1"` or `"0"` (never a JSON boolean). Every multi-select `OptionField`/
8
+ * `ModelRelationField`'s WRITE-side spelling is a SINGLE comma-joined string, not a JSON array.
9
+ *
10
+ * ⚠️ READ IS A THIRD SHAPE (OPNSENSE-2, distilled `homeflare/opnsense`'s `read-shape.ts`): the
11
+ * SAME list-shaped fields decode on GET as an option map `{key: {value, selected}}`, not a
12
+ * string — `AliasItem`/`GroupItem` (this family's OLD, pre-fix type references) were the
13
+ * WRITE-shaped structs; `fetchLive` actually decodes `ModelAliasReadItem`/
14
+ * `ModelIfgroupentryReadItem` (the WHOLE-MODEL GET's read-shaped item), whose `type`/
15
+ * `interface`/`proto`/`categories`/`members` are option maps. `selectedOf`/`selectedOneOf`
16
+ * below extract the selected key(s) back to the SAME plain-string/string[] shape `matches`
17
+ * already compares, so the rest of this family's logic (and `AliasAttributes`/
18
+ * `GroupAttributes`'s own field types) is unchanged.
11
19
  *
12
20
  * ⚠️ COMMA-LIST ORDER ROUND-TRIP IS NOT ASSUMED STABLE. OPNsense builds these from a PHP
13
- * associative array (`ModelRelationField`/`OptionField::getNodeData()`), and nothing in the
14
- * model XML promises the join order survives a save-then-read unchanged. Sorting and
15
- * deduplicating both sides before comparing is what makes `matches` (alias-form.ts,
16
- * group-form.ts) actually settle instead of reporting drift on a re-ordered list that changed
17
- * nothing — the same reasoning `../proxmox/user-wire.ts`'s `groupSet` documents for PVE's
18
- * `groups` field.
21
+ * associative array, and nothing in the model XML promises the join order survives a
22
+ * save-then-read unchanged. Sorting and deduplicating both sides before comparing is what makes
23
+ * `matches` (alias-form.ts, group-form.ts) actually settle instead of reporting drift on a
24
+ * re-ordered list that changed nothing — the same reasoning `../proxmox/user-wire.ts`'s
25
+ * `groupSet` documents for PVE's `groups` field.
19
26
  */
20
27
 
21
28
  /** OPNsense's own boolean spelling. Absent takes the caller's stated default — OPNsense's own
@@ -54,3 +61,41 @@ export const numField = (value: string | undefined, fallback: number): number =>
54
61
  const parsed = Number(value);
55
62
  return Number.isFinite(parsed) ? parsed : fallback;
56
63
  };
64
+
65
+ /** One entry of a GET-decoded option map — `{value: <label>, selected: 0|1}`, see this file's
66
+ * module doc. Structural, not imported from the SDK: every generated `*Map` field shares this
67
+ * exact shape (`OptionEntry` in each service module), and staying structural here avoids a
68
+ * per-resource-file type import just for this. */
69
+ export interface OpnsenseOptionEntry {
70
+ readonly value: string;
71
+ readonly selected: number;
72
+ }
73
+
74
+ /**
75
+ * The selected key(s) of a GET-decoded option map, normalised the same way `csvSet` normalises
76
+ * the write-side comma string — sorted, deduplicated, empty-key filtered. Use for a
77
+ * `Multiple="Y"` field (`AliasItem.proto`, `AliasItem.categories`, `GroupItem.members`); for a
78
+ * single-select field use `selectedOneOf`.
79
+ */
80
+ export const selectedOf = (
81
+ map: Readonly<Record<string, OpnsenseOptionEntry | undefined>> | undefined,
82
+ ): readonly string[] => {
83
+ if (map === undefined) return [];
84
+ const keys = Object.entries(map)
85
+ .filter(([key, entry]) => key !== '' && entry?.selected === 1)
86
+ .map(([key]) => key);
87
+ return [...new Set(keys)].sort();
88
+ };
89
+
90
+ /** Single-select convenience over `selectedOf` — the one selected key, or `''` if none (the same
91
+ * "absent" spelling a plain string field uses, e.g. `AliasItem.type`/`GroupItem`'s own
92
+ * `.\InterfaceField` members when not `Multiple="Y"`). */
93
+ export const selectedOneOf = (
94
+ map: Readonly<Record<string, OpnsenseOptionEntry | undefined>> | undefined,
95
+ ): string => selectedOf(map)[0] ?? '';
96
+
97
+ /** The encode direction, for test fixtures: every listed key marked `selected: 1`, mirroring a
98
+ * real OPNsense GET response (`{[key]: {value, selected: 1}}`) — so a fixture exercises the exact
99
+ * decode path `selectedOf`/`selectedOneOf` handle, not the pre-fix plain-string shape. */
100
+ export const optionMap = (...keys: readonly string[]): Record<string, OpnsenseOptionEntry> =>
101
+ Object.fromEntries(keys.map((key) => [key, { value: key, selected: 1 }]));
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Pure unit tests for `judgeDisk` — the red-team C1 fix on PR 297. No network, no fake PVE.
3
- * ⛔ WHAT THESE PIN: a "fresh" declaration (new-disk GiB, or `storage:cloudinit`) never re-drifts
4
- * against an existing live volume in the same slot; a genuine volume/storage swap is refused,
5
- * never written; a size change is refused (no resize call exists here); an option-only change is
6
- * written on the LIVE volume id and LIVE size.
3
+ * ⛔ WHAT THESE PIN: a "fresh" declaration (new-disk GiB, `storage:cloudinit`, or
4
+ * `storage:0,import-from=...`) never re-drifts against an existing live volume in the same slot;
5
+ * a genuine volume/storage swap is refused, never written; a size change is refused (no resize
6
+ * call exists here); an option-only change is written on the LIVE volume id and LIVE size.
7
7
  */
8
8
  import { describe, expect, test } from 'bun:test';
9
9
  import { judgeDisk, parseDisk } from './qemu-volume.ts';
@@ -20,6 +20,145 @@ describe('a "fresh" declaration matches any existing volume in its slot', () =>
20
20
  });
21
21
  });
22
22
 
23
+ /**
24
+ * The disk-import convergence bug homeflare-proxmox PR 84's red team found (2026-09-26, real
25
+ * fake-PVE engine): `judgeDisk` never recognized `<storage>:0,import-from=<volid>` as a new-disk
26
+ * spelling, so it compared the literal volname `"0"` against PVE's live read-back of the real
27
+ * `vm-<vmid>-disk-<n>` it allocated on import and refused -- on the very deploy that created the
28
+ * disk, and on every plan after (Talos's `declareTalos` declares the import spelling every time,
29
+ * never switches to the resolved volname). Each `judgeDisk` call below stands in for one PVE read
30
+ * a real Talos deploy makes; none of them are a live PVE call.
31
+ */
32
+ describe('the import-from create spelling converges (PR 84 red team)', () => {
33
+ const IMPORT_DECLARED = 'cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw';
34
+
35
+ test('create: post-write verification reads back the real volume PVE allocated', () => {
36
+ // The deploy that creates the VM re-reads the config right after the PUT to verify it landed.
37
+ // Before the fix this was refused (declared volname "0" vs live "vm-10000-disk-0").
38
+ expect(judgeDisk('scsi0', IMPORT_DECLARED, 'cephtb4:vm-10000-disk-0,size=2G')).toEqual({});
39
+ });
40
+
41
+ test('re-plan: the same declared import spelling against the now-adopted live disk', () => {
42
+ // A later `bun run plan` fabricates the same live read as create did; declared is unchanged
43
+ // (declareTalos always emits import-from, never the resolved volname). Must still be a no-op.
44
+ const verdict = judgeDisk('scsi0', IMPORT_DECLARED, 'cephtb4:vm-10000-disk-0,size=2G');
45
+ expect(verdict.refuse).toBeUndefined();
46
+ expect(verdict.put).toBeUndefined();
47
+ });
48
+
49
+ test('second deploy: PVE has since filled in defaults the fresh check must not treat as drift', () => {
50
+ // pve-qemu-server fills in options nobody declared (ssd/discard/etc.). A "fresh" declaration
51
+ // skips option comparison entirely, so this must be a no-op too -- not a PUT trying to "fix"
52
+ // options that were never wrong, and not a resize of the placeholder "0" against the real size.
53
+ const verdict = judgeDisk(
54
+ 'scsi0',
55
+ IMPORT_DECLARED,
56
+ 'cephtb4:vm-10000-disk-0,size=2G,ssd=1,discard=on',
57
+ );
58
+ expect(verdict).toEqual({});
59
+ });
60
+
61
+ test('no resize is ever attempted for an import-from disk', () => {
62
+ // The live size (2G, from the imported image) never appears in a `put` -- there is no `put` at
63
+ // all, because `want.kind === 'fresh'` returns before size or options are ever inspected.
64
+ expect(judgeDisk('scsi0', IMPORT_DECLARED, 'cephtb4:vm-10000-disk-0,size=200G')).toEqual({});
65
+ });
66
+
67
+ test('a volid with its own colon (storage:path) still parses as one fresh spelling', () => {
68
+ // import-from points at cephfs-tb4:import/talos-factory.raw -- a second colon inside the value,
69
+ // which a colon-anchored regex could truncate. Confirms the whole declared string is consumed.
70
+ expect(
71
+ judgeDisk(
72
+ 'scsi0',
73
+ 'cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw',
74
+ 'cephtb4:vm-10042-disk-0,size=2G',
75
+ ),
76
+ ).toEqual({});
77
+ });
78
+
79
+ test('a genuine size mismatch on an already-adopted volume is still refused', () => {
80
+ // Not an import-from declaration: a plain adopted volume whose declared size disagrees with the
81
+ // live one. Must still hit the existing no-resize refusal, unchanged by the new spelling.
82
+ const verdict = judgeDisk(
83
+ 'scsi0',
84
+ 'cephtb4:vm-10000-disk-0,size=4G',
85
+ 'cephtb4:vm-10000-disk-0,size=2G',
86
+ );
87
+ expect(verdict.refuse).toMatch(/does not resize a disk/);
88
+ expect(verdict.put).toBeUndefined();
89
+ });
90
+
91
+ test('volname "0" without import-from is not swept into the new "fresh" spelling', () => {
92
+ // Guards NEW_IMPORT's literal ",import-from=" requirement: a coincidental "storage:0,k=v" (no
93
+ // import-from) must still be judged as a real volume named "0", not treated as always-matching.
94
+ const verdict = judgeDisk('scsi0', 'cephtb4:0,ssd=1', 'cephtb4:vm-10000-disk-0,size=2G');
95
+ expect(verdict.refuse).toMatch(/is not the live vm-10000-disk-0/);
96
+ });
97
+
98
+ test('a mismatched storage on an import-from declaration is still refused, not silently matched', () => {
99
+ // "fresh" only skips the volname/size/options checks -- the storage check above it still runs.
100
+ const verdict = judgeDisk(
101
+ 'scsi0',
102
+ 'cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw',
103
+ 'local-zfs:vm-10000-disk-0,size=2G',
104
+ );
105
+ expect(verdict.refuse).toMatch(/the volume is on local-zfs, declared on cephtb4/);
106
+ });
107
+ });
108
+
109
+ /**
110
+ * The LAND red team's Important finding on this same PR (2026-09-26): options declared alongside
111
+ * `import-from` were either silently dropped (declared after it -- `.+` swallowed them uncompared)
112
+ * or stranded the disk forever (declared before it -- missed the old regex, fell to the `volume`
113
+ * branch, and got refused against the real live volname just like the bug this file fixes). Fixed by
114
+ * detecting `import-from` after the general comma split instead of a position-anchored regex.
115
+ */
116
+ describe('options declared alongside import-from are enforced, in either order (LAND red team)', () => {
117
+ const IMPORT = 'cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw';
118
+
119
+ test('an option after import-from that already matches live is a noop', () => {
120
+ const verdict = judgeDisk(
121
+ 'scsi0',
122
+ `${IMPORT},iothread=1`,
123
+ 'cephtb4:vm-150-disk-0,iothread=1,size=2G',
124
+ );
125
+ expect(verdict).toEqual({});
126
+ });
127
+
128
+ test('an option after import-from that disagrees with live is written, on the live volume id', () => {
129
+ // Scenario B from the red team: iothread was declared and honored at create; discard/ssd are
130
+ // added later. Before this fix, verify said `noop` and the deploy wrote nothing.
131
+ const verdict = judgeDisk(
132
+ 'scsi0',
133
+ `${IMPORT},iothread=1,discard=on,ssd=1`,
134
+ 'cephtb4:vm-150-disk-0,iothread=1,size=2G',
135
+ );
136
+ expect(verdict.put).toBe('cephtb4:vm-150-disk-0,iothread=1,discard=on,ssd=1,size=2G');
137
+ });
138
+
139
+ test('an option BEFORE import-from is still recognized as fresh, not a stranded volume', () => {
140
+ // Scenario C from the red team: `cephtb4:0,iothread=1,import-from=...` missed the old
141
+ // position-anchored regex entirely and was refused forever as a volname "0" vs the live one.
142
+ const verdict = judgeDisk(
143
+ 'scsi0',
144
+ 'cephtb4:0,iothread=1,import-from=cephfs-tb4:import/talos-factory.raw',
145
+ 'cephtb4:vm-150-disk-0,iothread=1,size=2G',
146
+ );
147
+ expect(verdict).toEqual({});
148
+ });
149
+
150
+ test('a live-only default the caller never declared is not treated as drift', () => {
151
+ // Only iothread was declared; ssd/discard are PVE's own fill-in. A full-map compare (sameMap)
152
+ // would call this drift and rewrite the disk on every plan -- the subset check must not.
153
+ const verdict = judgeDisk(
154
+ 'scsi0',
155
+ `${IMPORT},iothread=1`,
156
+ 'cephtb4:vm-150-disk-0,iothread=1,size=2G,ssd=1,discard=on',
157
+ );
158
+ expect(verdict).toEqual({});
159
+ });
160
+ });
161
+
23
162
  describe('an existing-volume declaration', () => {
24
163
  const live = 'cephtb4:vm-101-disk-0,size=32G,ssd=1';
25
164
 
@@ -77,4 +216,14 @@ describe('parseDisk', () => {
77
216
  expect(disk.size).toBe('32G');
78
217
  expect(disk.options).toEqual(new Map([['ssd', '1']]));
79
218
  });
219
+
220
+ test('the import-from spelling parses as fresh, same shape as the other two', () => {
221
+ expect(parseDisk('cephtb4:0,import-from=cephfs-tb4:import/talos-factory.raw')).toEqual({
222
+ kind: 'fresh',
223
+ options: new Map(),
224
+ size: undefined,
225
+ source: 'cephtb4',
226
+ volname: '',
227
+ });
228
+ });
80
229
  });
@@ -11,15 +11,48 @@
11
11
  * creates a fresh cloud-init seed drive; the live slot then reads back
12
12
  * `<storage>:vm-<vmid>-cloudinit,media=cdrom`. Both spellings compare equal to an existing volume
13
13
  * already in the slot, for the same reason `storage:GiB` does.
14
+ * ⛔ A THIRD NEW-VOLUME SPELLING, FOUND BY homeflare-proxmox PR 84's RED TEAM (2026-09-26, real
15
+ * fake-PVE engine): `<storage>:0,import-from=<volid>` imports a disk from another volume or a
16
+ * downloaded image at create time. Before this fix it fell through to the `volume` branch below
17
+ * (it has a comma, so `NEW_DISK` never matched it), so `want.volname` stayed the literal `"0"`
18
+ * forever while PVE's live read-back reported the real `vm-<vmid>-disk-<n>` it allocated on
19
+ * import -- a volname mismatch, REFUSED. That refusal fired on the very deploy that created the
20
+ * disk (post-write verification reads the config right back) and on every plan after, because
21
+ * the declared value is `import-from=...` again each time (Talos's `declareTalos` recomputes it
22
+ * from the download resource, never switches to the resolved volname). Fixed the same way as the
23
+ * other two: an `import-from` disk also counts as `kind: 'fresh'`, which already matches whatever
24
+ * is live unconditionally when nothing else is declared (see `want.kind === 'fresh'` below) -- no
25
+ * special-casing of the live side needed.
26
+ * ⛔ IMPORT-FROM IMPORTS ONLY AT CREATE. Changing just the `import-from=` *source* (e.g. bumping a
27
+ * factory image path) on an already-created disk still reads as `noop`: PVE does not re-import an
28
+ * existing volume, and this resource has no rebuild-the-disk path. That is correct for Talos, which
29
+ * upgrades in place through `talosctl`, not by recreating its disk -- a caller that DOES want a
30
+ * fresh import on image change must delete and recreate the row itself.
31
+ * ⛔ LAND RED TEAM (2026-09-26): OPTIONS DECLARED ALONGSIDE `import-from` MUST STILL BE ENFORCED, AND
32
+ * MUST BE RECOGNIZED REGARDLESS OF WHERE `import-from` SITS IN THE LIST. The first cut matched
33
+ * `import-from` only when it came immediately after `:0,`, via a regex whose `.+` then swallowed
34
+ * every option after it uncompared (silently never written) while any option declared BEFORE it
35
+ * (`cephtb4:0,iothread=1,import-from=...`) missed the regex entirely and fell to the `volume`
36
+ * branch, comparing literal volname `"0"` against the live real one forever -- REFUSED, same
37
+ * stranded-disk failure this file exists to fix, just for a different option order. Fixed by
38
+ * folding the import check into the general `storage:volname,k=v,...` parse below: ANY declaration
39
+ * whose volname is the literal `"0"` and whose options include an `import-from` key is `fresh`,
40
+ * with `import-from` itself dropped and every other declared option carried through for
41
+ * `judgeDisk` to enforce (see its `want.kind === 'fresh'` branch) -- independent of option order.
14
42
  * ⛔ NO RESIZE IS MADE HERE. lxc-volume.ts drives a second endpoint (`PUT .../resize`) this resource
15
43
  * does not call — Talos's disks are declared once at their create size. A size difference between
16
44
  * two already-adopted volume declarations is REFUSED with the `qm resize` to run by hand, never
17
45
  * guessed at or silently written.
18
- * ⚠️ OPTIONS BEYOND `size` ARE COMPARED AS A WHOLE, NOT KEY BY KEY. Unlike lxc-volume.ts, this file
19
- * does not model pve-qemu-server's per-option defaults (`ssd`, `discard`, `iothread`, …) — that
20
- * table is unverified here. So an option difference is real drift and gets written, exactly as the
21
- * whole-string comparison this replaces already did; only the two "new volume" spellings and the
22
- * live volume id/size are special-cased, which is the minimum that fixes the measured bug.
46
+ * ⚠️ OPTIONS BEYOND `size` ARE COMPARED AS A WHOLE, NOT KEY BY KEY, FOR AN ADOPTED `volume` -- BUT AS
47
+ * A SUBSET FOR A `fresh` IMPORT. Unlike lxc-volume.ts, this file does not model pve-qemu-server's
48
+ * per-option defaults (`ssd`, `discard`, `iothread`, …) — that table is unverified here. For an
49
+ * already-adopted volume, any options difference from live is real drift and gets written in full,
50
+ * exactly as the whole-string comparison this replaces already did. An `import-from` disk cannot
51
+ * use that same whole-map comparison: nobody declares PVE's post-import defaults, so comparing the
52
+ * full map would treat every default PVE fills in as drift and rewrite the disk on every plan (the
53
+ * builder's "second deploy" test below pins this). So a `fresh` import instead checks only the
54
+ * options the caller actually declared against live, ignoring any live-only keys, and PUTs the
55
+ * declared options (not merged with what live already has) when one of them disagrees.
23
56
  */
24
57
  import { sameMap } from './lxc-wire.ts';
25
58
 
@@ -29,7 +62,8 @@ const NEW_DISK = /^([^:\s]+):(\d+(?:\.\d+)?)$/;
29
62
  const NEW_CLOUDINIT = /^([^:\s]+):cloudinit$/;
30
63
 
31
64
  export type Disk = {
32
- /** `fresh`: either new-disk spelling above. `volume`: an existing volume, `storage:volname,...`. */
65
+ /** `fresh`: a new-disk spelling (`storage:GiB`, `storage:cloudinit`, or `storage:0,import-from=...`
66
+ * possibly with more options). `volume`: an existing volume, `storage:volname,...`. */
33
67
  readonly kind: 'fresh' | 'volume';
34
68
  readonly source: string;
35
69
  readonly volname: string;
@@ -37,15 +71,24 @@ export type Disk = {
37
71
  readonly options: Map<string, string>;
38
72
  };
39
73
 
40
- /** `storage:volname,k=v,...` (or one of the two "fresh" spellings) into its comparable parts. */
74
+ /**
75
+ * `storage:volname,k=v,...` (or one of the "fresh" spellings) into its comparable parts.
76
+ * ⚠️ THE IMPORT SPELLING IS DETECTED AFTER THE GENERAL SPLIT, NOT BY ITS OWN REGEX: `import-from`'s
77
+ * value can itself contain a colon (e.g. `cephfs-tb4:import/talos-factory.raw`) and other options
78
+ * can come before or after it, so a regex anchored on `:0,import-from=` right after the colon (as
79
+ * the first cut had) misses that ordering and swallows everything after it into one opaque match.
80
+ * Splitting on commas like any `volume` spelling first, THEN checking for a literal `"0"` volname
81
+ * plus an `import-from` key among the parsed options, catches every ordering and keeps every other
82
+ * declared option (`iothread=1`, `discard=on`, …) visible to `judgeDisk` instead of swallowed.
83
+ */
41
84
  export const parseDisk = (text: string): Disk => {
42
- const fresh = NEW_DISK.exec(text) ?? NEW_CLOUDINIT.exec(text);
43
- if (fresh !== null) {
85
+ const bare = NEW_DISK.exec(text) ?? NEW_CLOUDINIT.exec(text);
86
+ if (bare !== null) {
44
87
  return {
45
88
  kind: 'fresh',
46
89
  options: new Map(),
47
90
  size: undefined,
48
- source: fresh[1] ?? '',
91
+ source: bare[1] ?? '',
49
92
  volname: '',
50
93
  };
51
94
  }
@@ -61,6 +104,10 @@ export const parseDisk = (text: string): Disk => {
61
104
  );
62
105
  const size = options.get('size');
63
106
  options.delete('size');
107
+ if (volname === '0' && options.has('import-from')) {
108
+ options.delete('import-from');
109
+ return { kind: 'fresh', options, size: undefined, source, volname: '' };
110
+ }
64
111
  return { kind: 'volume', options, size, source, volname };
65
112
  };
66
113
 
@@ -97,10 +144,20 @@ export const judgeDisk = (key: string, declared: string, live: string): DiskVerd
97
144
  'between storages is not an update this resource makes -- move it by hand, then declare it.',
98
145
  };
99
146
  }
100
- // ⛔ A "fresh" declaration always matches an existing volume already in this slot: PVE holds one
101
- // there, and re-sending storage:GiB / storage:cloudinit would allocate a SECOND volume and park
102
- // the live one at unusedN (the header's ⛔, ported from lxc-volume.ts's own).
103
- if (want.kind === 'fresh') return {};
147
+ // ⛔ A "fresh" declaration never re-triggers create: PVE holds a real volume in this slot already,
148
+ // and re-sending storage:GiB / storage:cloudinit / storage:0,import-from=... as the PUT value
149
+ // would allocate a SECOND volume and park the live one at unusedN (the header's ⛔, ported from
150
+ // lxc-volume.ts's own). So a `fresh` PUT, when one is needed, is always built from the LIVE
151
+ // volume id (`have.volname`/`have.size`), never the declared placeholder -- same as `volume`.
152
+ // ⚠️ ONLY THE OPTIONS THE CALLER ACTUALLY DECLARED ARE CHECKED (a subset, not `sameMap`): nobody
153
+ // declares PVE's post-import defaults, so an empty `want.options` (plain import-from, nothing
154
+ // else declared) is always a noop no matter what live has filled in, and a non-empty one is
155
+ // compared key-by-key against live, ignoring any key live has that the caller never declared.
156
+ if (want.kind === 'fresh') {
157
+ if (want.options.size === 0) return {};
158
+ if ([...want.options].every(([k, v]) => have.options.get(k) === v)) return {};
159
+ return { put: printOptions(have.source, have.volname, want.options, have.size) };
160
+ }
104
161
  if (want.volname !== have.volname) {
105
162
  return {
106
163
  refuse:
@@ -4,7 +4,10 @@
4
4
  * both files under the 250-line cap — the api-token.ts/api-token-form.ts seam, same reasoning.
5
5
  */
6
6
  import type * as storage from '@distilled.cloud/proxmox/storage';
7
+ import { sharedAccepted } from './storage-plugin-options.ts';
7
8
  import type { StorageProps } from './storage.ts';
9
+ import { changedProps } from './storage-wire.ts';
10
+ import type { StorageAttributes } from './storage-wire.ts';
8
11
  import { flag } from './values.ts';
9
12
 
10
13
  export const STORAGE_CREATE = 'pve:POST /storage';
@@ -75,6 +78,16 @@ const locatorForm = (locator: StorageLocator | undefined) =>
75
78
  const underscored = (form: Record<string, string>): Record<string, string> =>
76
79
  Object.fromEntries(Object.entries(form).map(([key, value]) => [key.replaceAll('-', '_'), value]));
77
80
 
81
+ /**
82
+ * The 7 fields `mutable()` reads, narrowed out of `StorageProps` — not `StorageProps` itself, so
83
+ * a partial diff (storage-wire.ts's `changedProps`, which carries none of `storage`/`locator`/
84
+ * `target`) can satisfy this type directly.
85
+ */
86
+ export type MutableFields = Pick<
87
+ StorageProps,
88
+ 'comment' | 'content' | 'disable' | 'nodes' | 'preallocation' | 'prune-backups' | 'shared'
89
+ >;
90
+
78
91
  /**
79
92
  * The mutable half, for create and update alike.
80
93
  *
@@ -93,15 +106,21 @@ const underscored = (form: Record<string, string>): Record<string, string> =>
93
106
  * vendor-constraint table is keyed by PVE's own names (`underscored`'s own header has the
94
107
  * measured evidence). `toDistilledCreate`/`toDistilledUpdate` translate a COPY for the actual
95
108
  * SDK call; this function and its callers never see the underscored form.
109
+ *
110
+ * ⛔ `shared` IS GATED BY TYPE — `sharedAccepted` (storage-plugin-options.ts, vendor-cited): most
111
+ * plugins refuse it outright ("unexpected property 'shared'", the measured bug this gate
112
+ * fixes), so it is dropped here regardless of what the caller passed, for both create and
113
+ * update alike — `type` is always required precisely so this gate can run on the FULL declared
114
+ * set at create time too, not only on a partial update diff.
96
115
  */
97
- const mutable = (props: StorageProps) => ({
116
+ const mutable = (type: string, props: MutableFields) => ({
98
117
  ...field('comment', props.comment),
99
118
  ...field('content', props.content),
100
119
  ...field('disable', flag(props.disable)),
101
120
  ...field('nodes', props.nodes),
102
121
  ...field('preallocation', props.preallocation),
103
122
  ...field('prune-backups', props['prune-backups']),
104
- ...field('shared', flag(props.shared)),
123
+ ...(sharedAccepted(type) ? field('shared', flag(props.shared)) : {}),
105
124
  });
106
125
 
107
126
  /**
@@ -113,7 +132,7 @@ const mutable = (props: StorageProps) => ({
113
132
  */
114
133
  export const createForm = (props: StorageProps): Record<string, string> => ({
115
134
  ...locatorForm(props.locator),
116
- ...mutable(props),
135
+ ...mutable(props.type, props),
117
136
  storage: props.storage,
118
137
  type: props.type,
119
138
  });
@@ -123,9 +142,23 @@ export const createForm = (props: StorageProps): Record<string, string> => ({
123
142
  * (`PutStorageRequest` has no `type`, and the plugin locator fields it does carry are for a
124
143
  * different purpose: moving an existing volume, not re-pointing the definition), and comparing
125
144
  * either could only plan an update that no write can apply: a plan that reports work forever.
145
+ *
146
+ * ★ PARTIAL ON A KNOWN LIVE OBJECT — THE FIX FOR THE MEASURED BUG. `live === undefined` means
147
+ * there is no live row to diff against yet (`diff()`'s own early sanity check, run before any
148
+ * read; and the "about to create" branch in `reconcile()`) and this returns the FULL declared
149
+ * set, same as before this change. Once `live` is a real read, only `changedProps`
150
+ * (storage-wire.ts) — the declared fields that actually differ from what PVE just reported —
151
+ * reach the wire. Before this, `reconcile` PUT the full declared set whenever ANYTHING
152
+ * differed, so a declared `shared` that already MATCHED live still rode along on every update;
153
+ * for `storage-cephfs-tb4` (`content` was the only real drift, measured 2026-09-27) that meant
154
+ * sending `shared` to a plugin whose own `options()` never accepts it (storage-plugin-options.ts
155
+ * has the vendor citation), and PVE answered `unexpected property 'shared'`.
126
156
  */
127
- export const updateForm = (props: StorageProps): Record<string, string> => ({
128
- ...mutable(props),
157
+ export const updateForm = (
158
+ props: StorageProps,
159
+ live: StorageAttributes | undefined,
160
+ ): Record<string, string> => ({
161
+ ...mutable(props.type, live === undefined ? props : changedProps(live, props)),
129
162
  storage: props.storage,
130
163
  });
131
164
 
@@ -139,6 +172,13 @@ export const updateForm = (props: StorageProps): Record<string, string> => ({
139
172
  export const toDistilledCreate = (props: StorageProps): storage.CreateStorageRequest =>
140
173
  underscored(createForm(props)) as unknown as storage.CreateStorageRequest;
141
174
 
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;
175
+ /**
176
+ * The actual `putStorage` call body — translated once. See `toDistilledCreate`.
177
+ * ⚠️ TAKES THE ALREADY-BUILT FORM, NOT `props`+`live` — storage.ts's `reconcile` computes
178
+ * `updateForm(news, before)` exactly ONCE and passes that SAME object here and to `guardWrite`,
179
+ * so the vendor-constraint check and the wire body can never diverge. Two independent calls to
180
+ * `updateForm`, each running its own `changedProps`, would still agree today, but "the guard
181
+ * checks what is actually sent" (the house rule this fix follows) should not depend on that.
182
+ */
183
+ export const toDistilledUpdate = (form: Record<string, string>): storage.PutStorageRequest =>
184
+ underscored(form) as unknown as storage.PutStorageRequest;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Which PVE storage plugin TYPES accept the `shared` property on write (create or update) — from
3
+ * each plugin's own `options()` in vendor source, NOT the generic combined property list
4
+ * `pve-apidoc` documentation carries (storage.ts's own header has that gap: apidoc.js merges
5
+ * every plugin's fields into one flat list for display, so `shared` LOOKS like a plain valid
6
+ * property of PUT /storage/{storage} there — `generated/pve/storage.ts`'s own
7
+ * `StorageStoragePutParams.shared` came from exactly that merged list, and so did the constraint
8
+ * codegen's own inclusion of `shared` as a recognized wire field). The check PVE actually runs is
9
+ * PER STORAGE TYPE, against that concrete plugin's own `options()` accepted-key set
10
+ * (PVE::SectionConfig) — a key the plugin never names is refused outright, which is why a cephfs
11
+ * PUT carrying `shared` answers `unexpected property 'shared'` even though the apidoc schema and
12
+ * the generated wire types both carry the field.
13
+ *
14
+ * MEASURED against github.com/proxmox/pve-storage (the official read-only GitHub mirror of
15
+ * git.proxmox.com/pve-storage.git), branch `master`, read 2026-09-27 — each plugin's own
16
+ * `sub options` block. Extended 2026-09-27 (PR 314's own red team) to cover the five types the
17
+ * first pass left unverified rather than guessed at; only these two lists moved:
18
+ *
19
+ * accepts `shared` (`shared => { optional => 1 }`):
20
+ * DirPlugin.pm:85, LVMPlugin.pm:453, BTRFSPlugin.pm:69, ESXiPlugin.pm:52
21
+ *
22
+ * `shared` is ABSENT from `options()` entirely — PVE's per-type accepted-key check refuses it
23
+ * on both create and update, whatever the combined apidoc schema shows:
24
+ * CephFSPlugin.pm, RBDPlugin.pm, ZFSPoolPlugin.pm, LvmThinPlugin.pm, NFSPlugin.pm,
25
+ * CIFSPlugin.pm, PBSPlugin.pm, ISCSIPlugin.pm, ISCSIDirectPlugin.pm, ZFSPlugin.pm (the remote
26
+ * ZFS-over-iSCSI plugin, distinct from `zfspool`)
27
+ *
28
+ * `storage-cephfs-tb4`'s PUT failure (measured 2026-09-27, `bun run deploy`,
29
+ * `InternalServerError: update storage failed: unexpected property 'shared'`) is exactly this:
30
+ * `cephfs` has no `shared` in its own `options()`, so the base class's generic `shared` property
31
+ * (`PVE::Storage::Plugin`'s `$defaultData->{propertyList}`: `type => 'boolean', optional => 1`,
32
+ * no `fixed`) is never in the accepted set for that plugin, whether or not the declared value
33
+ * would have matched what PVE already has.
34
+ *
35
+ * ⚠️ STILL NOT EXHAUSTIVE. Every PVE plugin type is now checked against vendor source EXCEPT
36
+ * whatever ships after this file was last read — `sharedAccepted` treats an unrecognized type
37
+ * as NOT accepting `shared`, the safe default: a wrongly-excluded type just never sends a
38
+ * declared `shared` (storage-form.ts's `mutable`) and, if the declared value actually disagrees
39
+ * with what PVE reports, the plan dies loud rather than lying `noop` (storage-wire.ts's
40
+ * `matches`) — it does not risk a second `unexpected property` 500. Read a new plugin's own
41
+ * `options()` on github.com/proxmox/pve-storage before adding it to either list.
42
+ */
43
+ const SHARED_ACCEPTED_TYPES: ReadonlySet<string> = new Set(['dir', 'lvm', 'btrfs', 'esxi']);
44
+
45
+ export const sharedAccepted = (type: string): boolean => SHARED_ACCEPTED_TYPES.has(type);