@homeflare/alchemy 0.38.0 → 0.40.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 (353) hide show
  1. package/README.md +32 -8
  2. package/dist/ceph/auth-entity.d.ts +34 -0
  3. package/dist/ceph/auth-entity.d.ts.map +1 -0
  4. package/dist/ceph/ceph-argv-config.d.ts +10 -0
  5. package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
  6. package/dist/ceph/ceph-argv.d.ts +19 -0
  7. package/dist/ceph/ceph-argv.d.ts.map +1 -0
  8. package/dist/ceph/ceph-auth-form.d.ts +54 -0
  9. package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
  10. package/dist/ceph/ceph-auth-parse.d.ts +56 -0
  11. package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
  12. package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
  13. package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
  14. package/dist/ceph/ceph-config-options.d.ts +23 -0
  15. package/dist/ceph/ceph-config-options.d.ts.map +1 -0
  16. package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
  17. package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
  18. package/dist/ceph/ceph-transport.d.ts +37 -0
  19. package/dist/ceph/ceph-transport.d.ts.map +1 -0
  20. package/dist/ceph/fake-ceph-dial.d.ts +35 -0
  21. package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
  22. package/dist/ceph/index.d.ts +13 -0
  23. package/dist/ceph/index.d.ts.map +1 -0
  24. package/dist/ceph/index.js +1609 -0
  25. package/dist/ceph/index.js.map +38 -0
  26. package/dist/forgejo/index.js +4 -2
  27. package/dist/forgejo/index.js.map +3 -3
  28. package/dist/forgejo/team-member.d.ts +25 -0
  29. package/dist/forgejo/team-member.d.ts.map +1 -1
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  33. package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
  34. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  35. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  36. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  37. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  38. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  39. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  40. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  41. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  42. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  43. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  44. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  45. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  46. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  47. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  48. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
  49. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
  50. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  51. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  52. package/dist/proxmox/index.d.ts +14 -3
  53. package/dist/proxmox/index.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +574 -133
  55. package/dist/proxmox/index.js.map +33 -23
  56. package/dist/proxmox/qemu-form.d.ts +27 -18
  57. package/dist/proxmox/qemu-form.d.ts.map +1 -1
  58. package/dist/proxmox/qemu-judge.d.ts +30 -0
  59. package/dist/proxmox/qemu-judge.d.ts.map +1 -0
  60. package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
  61. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
  62. package/dist/proxmox/qemu-net.d.ts +9 -0
  63. package/dist/proxmox/qemu-net.d.ts.map +1 -0
  64. package/dist/proxmox/qemu-props.d.ts +112 -0
  65. package/dist/proxmox/qemu-props.d.ts.map +1 -0
  66. package/dist/proxmox/qemu-volume.d.ts +27 -0
  67. package/dist/proxmox/qemu-volume.d.ts.map +1 -0
  68. package/dist/proxmox/qemu.d.ts +14 -27
  69. package/dist/proxmox/qemu.d.ts.map +1 -1
  70. package/dist/proxmox/storage-download-errors.d.ts +5 -0
  71. package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
  72. package/dist/proxmox/storage-download-identity.d.ts +27 -0
  73. package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
  74. package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
  75. package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
  76. package/dist/proxmox/storage-download-props.d.ts +36 -0
  77. package/dist/proxmox/storage-download-props.d.ts.map +1 -0
  78. package/dist/proxmox/storage-download-task.d.ts +17 -0
  79. package/dist/proxmox/storage-download-task.d.ts.map +1 -0
  80. package/dist/proxmox/storage-download.d.ts +44 -0
  81. package/dist/proxmox/storage-download.d.ts.map +1 -0
  82. package/dist/talos/credentials-write.d.ts +46 -0
  83. package/dist/talos/credentials-write.d.ts.map +1 -0
  84. package/dist/talos/credentials.d.ts +51 -9
  85. package/dist/talos/credentials.d.ts.map +1 -1
  86. package/dist/talos/fake-process.d.ts +56 -0
  87. package/dist/talos/fake-process.d.ts.map +1 -0
  88. package/dist/talos/index.d.ts +19 -4
  89. package/dist/talos/index.d.ts.map +1 -1
  90. package/dist/talos/index.js +357 -173
  91. package/dist/talos/index.js.map +16 -10
  92. package/dist/talos/kubeconfig-attrs.d.ts +4 -0
  93. package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
  94. package/dist/talos/kubeconfig.d.ts +41 -23
  95. package/dist/talos/kubeconfig.d.ts.map +1 -1
  96. package/dist/talos/machine-config-fixtures.d.ts +60 -0
  97. package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
  98. package/dist/talos/machine-config-poll.d.ts +43 -0
  99. package/dist/talos/machine-config-poll.d.ts.map +1 -0
  100. package/dist/talos/machine-config-read.d.ts +49 -0
  101. package/dist/talos/machine-config-read.d.ts.map +1 -0
  102. package/dist/talos/resource.d.ts +18 -0
  103. package/dist/talos/resource.d.ts.map +1 -1
  104. package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
  105. package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
  106. package/dist/talos/talos-bootstrap.d.ts +87 -0
  107. package/dist/talos/talos-bootstrap.d.ts.map +1 -1
  108. package/dist/talos/talos-cluster-health.d.ts +43 -1
  109. package/dist/talos/talos-cluster-health.d.ts.map +1 -1
  110. package/dist/talos/talos-errors.d.ts +68 -0
  111. package/dist/talos/talos-errors.d.ts.map +1 -0
  112. package/dist/talos/talos-machine-config.d.ts +51 -15
  113. package/dist/talos/talos-machine-config.d.ts.map +1 -1
  114. package/dist/talos/talosctl.d.ts +7 -0
  115. package/dist/talos/talosctl.d.ts.map +1 -1
  116. package/dist/talos/values.d.ts +23 -3
  117. package/dist/talos/values.d.ts.map +1 -1
  118. package/dist/telemetry/fake-collector.d.ts +36 -0
  119. package/dist/telemetry/fake-collector.d.ts.map +1 -0
  120. package/dist/telemetry/index.d.ts +11 -0
  121. package/dist/telemetry/index.d.ts.map +1 -0
  122. package/dist/telemetry/index.js +223 -0
  123. package/dist/telemetry/index.js.map +12 -0
  124. package/dist/telemetry/layer.d.ts +86 -0
  125. package/dist/telemetry/layer.d.ts.map +1 -0
  126. package/dist/telemetry/redact-serialization.d.ts +21 -0
  127. package/dist/telemetry/redact-serialization.d.ts.map +1 -0
  128. package/dist/telemetry/redact.d.ts +46 -0
  129. package/dist/telemetry/redact.d.ts.map +1 -0
  130. package/dist/unifi/acl-rule-drift.d.ts +22 -0
  131. package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
  132. package/dist/unifi/acl-rule-form.d.ts +90 -0
  133. package/dist/unifi/acl-rule-form.d.ts.map +1 -0
  134. package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
  135. package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
  136. package/dist/unifi/acl-rule-ordering.d.ts +54 -0
  137. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
  138. package/dist/unifi/acl-rule.d.ts +49 -0
  139. package/dist/unifi/acl-rule.d.ts.map +1 -0
  140. package/dist/unifi/dns-policy-drift.d.ts +21 -0
  141. package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
  142. package/dist/unifi/dns-policy-form.d.ts +97 -0
  143. package/dist/unifi/dns-policy-form.d.ts.map +1 -0
  144. package/dist/unifi/dns-policy.d.ts +55 -0
  145. package/dist/unifi/dns-policy.d.ts.map +1 -0
  146. package/dist/unifi/drift.d.ts +27 -0
  147. package/dist/unifi/drift.d.ts.map +1 -0
  148. package/dist/unifi/fake-unifi.d.ts +5 -1
  149. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  150. package/dist/unifi/firewall-policy-drift.d.ts +26 -0
  151. package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
  152. package/dist/unifi/firewall-policy-form.d.ts +102 -0
  153. package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
  154. package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
  155. package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
  156. package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
  157. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
  158. package/dist/unifi/firewall-policy.d.ts +57 -0
  159. package/dist/unifi/firewall-policy.d.ts.map +1 -0
  160. package/dist/unifi/firewall-zone-form.d.ts +6 -0
  161. package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
  162. package/dist/unifi/firewall-zone.d.ts +1 -1
  163. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  164. package/dist/unifi/index.d.ts +11 -3
  165. package/dist/unifi/index.d.ts.map +1 -1
  166. package/dist/unifi/index.js +725 -60
  167. package/dist/unifi/index.js.map +31 -9
  168. package/dist/unifi/network-drift.d.ts +26 -0
  169. package/dist/unifi/network-drift.d.ts.map +1 -0
  170. package/dist/unifi/network-form.d.ts +40 -7
  171. package/dist/unifi/network-form.d.ts.map +1 -1
  172. package/dist/unifi/network.d.ts +1 -0
  173. package/dist/unifi/network.d.ts.map +1 -1
  174. package/dist/unifi/paginate.d.ts +24 -0
  175. package/dist/unifi/paginate.d.ts.map +1 -0
  176. package/dist/unifi/providers.d.ts +1 -1
  177. package/dist/unifi/providers.d.ts.map +1 -1
  178. package/dist/unifi/resource.d.ts +36 -1
  179. package/dist/unifi/resource.d.ts.map +1 -1
  180. package/dist/unifi/scan-source.d.ts +11 -0
  181. package/dist/unifi/scan-source.d.ts.map +1 -0
  182. package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
  183. package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
  184. package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
  185. package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
  186. package/dist/unifi/traffic-matching-list.d.ts +51 -0
  187. package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
  188. package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
  189. package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
  190. package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
  191. package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
  192. package/dist/unifi/wifi-broadcast.d.ts +73 -0
  193. package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
  194. package/docs/telemetry-spike.md +94 -0
  195. package/docs/telemetry.md +109 -0
  196. package/docs/unifi-acl-rule.md +122 -0
  197. package/docs/unifi-dns-policy.md +75 -0
  198. package/docs/unifi-firewall-policy.md +109 -0
  199. package/docs/unifi-traffic-matching-list.md +82 -0
  200. package/docs/unifi-wifi.md +120 -0
  201. package/docs/unifi.md +64 -3
  202. package/package.json +10 -2
  203. package/src/ceph/auth-entity.ts +74 -0
  204. package/src/ceph/ceph-argv-config.ts +78 -0
  205. package/src/ceph/ceph-argv.test.ts +226 -0
  206. package/src/ceph/ceph-argv.ts +189 -0
  207. package/src/ceph/ceph-auth-form.test.ts +103 -0
  208. package/src/ceph/ceph-auth-form.ts +107 -0
  209. package/src/ceph/ceph-auth-parse.test.ts +155 -0
  210. package/src/ceph/ceph-auth-parse.ts +121 -0
  211. package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
  212. package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
  213. package/src/ceph/ceph-auth-reconcile.ts +119 -0
  214. package/src/ceph/ceph-config-options.test.ts +44 -0
  215. package/src/ceph/ceph-config-options.ts +36 -0
  216. package/src/ceph/ceph-key-leak-assert.ts +35 -0
  217. package/src/ceph/ceph-no-output-logging.test.ts +52 -0
  218. package/src/ceph/ceph-transport.test.ts +125 -0
  219. package/src/ceph/ceph-transport.ts +117 -0
  220. package/src/ceph/fake-ceph-dial.ts +55 -0
  221. package/src/ceph/index.ts +12 -0
  222. package/src/forgejo/team-member.test.ts +14 -0
  223. package/src/forgejo/team-member.ts +28 -1
  224. package/src/index.ts +1 -0
  225. package/src/proxmox/constraints-required.test.ts +1 -0
  226. package/src/proxmox/generated/constraints/index.ts +3 -1
  227. package/src/proxmox/generated/constraints/pve-access.ts +1 -1
  228. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  229. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  230. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  231. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  232. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  233. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  234. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  235. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  236. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  237. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  238. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  239. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  240. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  241. package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
  242. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  243. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  244. package/src/proxmox/index.test.ts +11 -6
  245. package/src/proxmox/index.ts +19 -3
  246. package/src/proxmox/qemu-form.ts +49 -13
  247. package/src/proxmox/qemu-identity.test.ts +30 -0
  248. package/src/proxmox/qemu-judge.test.ts +65 -0
  249. package/src/proxmox/qemu-judge.ts +84 -0
  250. package/src/proxmox/qemu-lifecycle.test.ts +78 -19
  251. package/src/proxmox/qemu-lifecycle.ts +131 -32
  252. package/src/proxmox/qemu-net.test.ts +48 -0
  253. package/src/proxmox/qemu-net.ts +78 -0
  254. package/src/proxmox/qemu-ownership.test.ts +44 -0
  255. package/src/proxmox/qemu-props.test.ts +87 -0
  256. package/src/proxmox/qemu-props.ts +157 -0
  257. package/src/proxmox/qemu-volume.test.ts +229 -0
  258. package/src/proxmox/qemu-volume.ts +178 -0
  259. package/src/proxmox/qemu.ts +15 -29
  260. package/src/proxmox/storage-download-errors.ts +7 -0
  261. package/src/proxmox/storage-download-identity.test.ts +54 -0
  262. package/src/proxmox/storage-download-identity.ts +50 -0
  263. package/src/proxmox/storage-download-lifecycle.ts +226 -0
  264. package/src/proxmox/storage-download-ownership.test.ts +50 -0
  265. package/src/proxmox/storage-download-props.ts +38 -0
  266. package/src/proxmox/storage-download-task.ts +72 -0
  267. package/src/proxmox/storage-download.test.ts +216 -0
  268. package/src/proxmox/storage-download.ts +58 -0
  269. package/src/talos/credentials-write.test.ts +81 -0
  270. package/src/talos/credentials-write.ts +95 -0
  271. package/src/talos/credentials.test.ts +157 -0
  272. package/src/talos/credentials.ts +160 -90
  273. package/src/talos/fake-process.ts +94 -0
  274. package/src/talos/index.ts +39 -4
  275. package/src/talos/kubeconfig-attrs.ts +55 -0
  276. package/src/talos/kubeconfig.test.ts +256 -0
  277. package/src/talos/kubeconfig.ts +170 -90
  278. package/src/talos/machine-config-fixtures.ts +86 -0
  279. package/src/talos/machine-config-poll.test.ts +57 -0
  280. package/src/talos/machine-config-poll.ts +82 -0
  281. package/src/talos/machine-config-read.ts +118 -0
  282. package/src/talos/resource.ts +19 -0
  283. package/src/talos/secrets-gate.test.ts +63 -0
  284. package/src/talos/talos-bootstrap-etcd.ts +71 -0
  285. package/src/talos/talos-bootstrap.test.ts +218 -0
  286. package/src/talos/talos-bootstrap.ts +151 -45
  287. package/src/talos/talos-cluster-health.test.ts +143 -0
  288. package/src/talos/talos-cluster-health.ts +91 -28
  289. package/src/talos/talos-errors.ts +85 -0
  290. package/src/talos/talos-machine-config.test.ts +189 -0
  291. package/src/talos/talos-machine-config.ts +102 -88
  292. package/src/talos/talosctl.ts +8 -0
  293. package/src/talos/values.test.ts +34 -6
  294. package/src/talos/values.ts +41 -7
  295. package/src/telemetry/fake-collector.ts +59 -0
  296. package/src/telemetry/http-client-sealing.test.ts +82 -0
  297. package/src/telemetry/index.ts +10 -0
  298. package/src/telemetry/layer.test.ts +204 -0
  299. package/src/telemetry/layer.ts +152 -0
  300. package/src/telemetry/redact-serialization.ts +178 -0
  301. package/src/telemetry/redact.test.ts +124 -0
  302. package/src/telemetry/redact.ts +147 -0
  303. package/src/unifi/acl-rule-drift.ts +57 -0
  304. package/src/unifi/acl-rule-form.ts +135 -0
  305. package/src/unifi/acl-rule-ordering-form.ts +79 -0
  306. package/src/unifi/acl-rule-ordering.test.ts +141 -0
  307. package/src/unifi/acl-rule-ordering.ts +70 -0
  308. package/src/unifi/acl-rule.test.ts +183 -0
  309. package/src/unifi/acl-rule.ts +61 -0
  310. package/src/unifi/discriminated-filter-decode.test.ts +134 -0
  311. package/src/unifi/dns-policy-drift.ts +48 -0
  312. package/src/unifi/dns-policy-form.ts +142 -0
  313. package/src/unifi/dns-policy.test.ts +154 -0
  314. package/src/unifi/dns-policy.ts +67 -0
  315. package/src/unifi/drift.test.ts +78 -0
  316. package/src/unifi/drift.ts +56 -0
  317. package/src/unifi/errors-and-secrets.test.ts +127 -0
  318. package/src/unifi/fake-unifi.ts +5 -1
  319. package/src/unifi/firewall-policy-drift.test.ts +151 -0
  320. package/src/unifi/firewall-policy-drift.ts +55 -0
  321. package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
  322. package/src/unifi/firewall-policy-form.ts +146 -0
  323. package/src/unifi/firewall-policy-ordering-form.ts +111 -0
  324. package/src/unifi/firewall-policy-ordering.test.ts +185 -0
  325. package/src/unifi/firewall-policy-ordering.ts +87 -0
  326. package/src/unifi/firewall-policy.test.ts +183 -0
  327. package/src/unifi/firewall-policy.ts +73 -0
  328. package/src/unifi/firewall-zone-form.ts +31 -2
  329. package/src/unifi/firewall-zone.test.ts +21 -1
  330. package/src/unifi/firewall-zone.ts +1 -1
  331. package/src/unifi/get-only-guard.test.ts +165 -0
  332. package/src/unifi/index.ts +78 -1
  333. package/src/unifi/network-drift.test.ts +207 -0
  334. package/src/unifi/network-drift.ts +80 -0
  335. package/src/unifi/network-form.ts +11 -39
  336. package/src/unifi/network.test.ts +7 -82
  337. package/src/unifi/network.ts +6 -6
  338. package/src/unifi/paginate.test.ts +252 -0
  339. package/src/unifi/paginate.ts +141 -0
  340. package/src/unifi/providers.ts +19 -1
  341. package/src/unifi/resource.ts +73 -3
  342. package/src/unifi/scan-source.ts +64 -0
  343. package/src/unifi/traffic-matching-list-drift.ts +40 -0
  344. package/src/unifi/traffic-matching-list-form.ts +77 -0
  345. package/src/unifi/traffic-matching-list.test.ts +202 -0
  346. package/src/unifi/traffic-matching-list.ts +78 -0
  347. package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
  348. package/src/unifi/wifi-broadcast-drift.ts +69 -0
  349. package/src/unifi/wifi-broadcast-form.ts +211 -0
  350. package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
  351. package/src/unifi/wifi-broadcast.test.ts +155 -0
  352. package/src/unifi/wifi-broadcast.ts +90 -0
  353. package/src/unifi/write-op-reference.test.ts +190 -0
@@ -5,7 +5,7 @@ import * as Effect from 'effect/Effect';
5
5
  import * as Layer from 'effect/Layer';
6
6
  import { engineOver } from '../verify/fake-engine.ts';
7
7
  import { FAKE_TARGET, type FakePve, fakePve, withoutBao } from './fake-pve.ts';
8
- import { ProxmoxVm, ProxmoxVmProvider } from './qemu.ts';
8
+ import { ProxmoxVm, ProxmoxVmProvider, type VmProps } from './qemu.ts';
9
9
 
10
10
  const NODE = 'pve1';
11
11
  const VMID = 150;
@@ -42,24 +42,27 @@ describe('Proxmox.Vm distilled transport', () => {
42
42
  });
43
43
 
44
44
  test('absence creates on the collection route, waits, then settles', async () => {
45
- let exists = false;
45
+ let posted: Record<string, string> | undefined;
46
46
  const fake = fakePve((call) => {
47
47
  if (call.path.includes('/tasks/') && call.path.endsWith('/status')) {
48
48
  return { status: 'stopped', exitstatus: 'OK' };
49
49
  }
50
50
  if (isIndex(call)) return [];
51
- if (call.method === 'GET') return exists ? live : missing();
52
- exists = true;
51
+ // ⚠️ `net0` IS NOW A MANAGED, COMPARED KEY (qemu-props.ts) — the old fixture answered the
52
+ // SAME static `live` regardless of what was posted, which only worked because the old
53
+ // `matches` never looked at `net0` at all. Echoing the create form back is what proves
54
+ // the declared value is what a real PVE config would report.
55
+ if (call.method === 'GET') return posted === undefined ? missing() : { ...live, ...posted };
56
+ posted = call.form;
53
57
  return UPID;
54
58
  });
59
+ const declared = { ...props, net0: 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0' };
55
60
  await withoutBao(async () => {
56
61
  const stack = engine(fake);
57
- await stack.deploy(
58
- ProxmoxVm('row', { ...props, net0: 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0' }),
62
+ await stack.deploy(ProxmoxVm('row', declared));
63
+ expect((await stack.verify(ProxmoxVm('row', declared), { all: true })).rows[0]).toMatchObject(
64
+ { diff: 'noop' },
59
65
  );
60
- expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
61
- diff: 'noop',
62
- });
63
66
  });
64
67
  expect(fake.writes()).toEqual([`POST nodes/${NODE}/qemu`]);
65
68
  expect(fake.calls.find((call) => call.method === 'POST')?.form['net0']).toBe(
@@ -68,6 +71,41 @@ describe('Proxmox.Vm distilled transport', () => {
68
71
  expect(fake.calls.some((call) => call.path.includes('/tasks/'))).toBe(true);
69
72
  });
70
73
 
74
+ test('C1: a "fresh" disk/cloudinit and a MAC-less NIC do not re-drift after PVE allocates them', async () => {
75
+ // ⛔ THE MEASURED REGRESSION: before qemu-judge.ts, every deploy after the create re-sent
76
+ // scsi0/ide2/net0 in their DECLARED (fresh) spelling, because a plain string compare never
77
+ // equals what PVE reports back for an allocated volume or a generated MAC -- reallocating the
78
+ // disk and handing out a new MAC on every single deploy.
79
+ const declared = {
80
+ ...props,
81
+ ide2: 'cephtb4:cloudinit',
82
+ net0: 'virtio,bridge=vmbr0,tag=20',
83
+ scsi0: 'cephtb4:32',
84
+ };
85
+ const allocated = {
86
+ ide2: 'cephtb4:vm-150-cloudinit,media=cdrom',
87
+ net0: 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=20',
88
+ scsi0: 'cephtb4:vm-150-disk-0,size=32G',
89
+ };
90
+ let created = false;
91
+ const fake = fakePve((call) => {
92
+ if (call.path.includes('/tasks/') && call.path.endsWith('/status')) {
93
+ return { status: 'stopped', exitstatus: 'OK' };
94
+ }
95
+ if (isIndex(call)) return [];
96
+ if (call.method === 'GET') return created ? { ...live, ...allocated } : missing();
97
+ created = true;
98
+ return UPID;
99
+ });
100
+ await withoutBao(async () => {
101
+ const stack = engine(fake);
102
+ await stack.deploy(ProxmoxVm('row', declared));
103
+ await stack.deploy(ProxmoxVm('row', declared));
104
+ await stack.deploy(ProxmoxVm('row', declared));
105
+ });
106
+ expect(fake.writes()).toEqual([`POST nodes/${NODE}/qemu`]);
107
+ });
108
+
71
109
  test('a transitional task status keeps polling instead of aborting the create', async () => {
72
110
  // ⚠️ `GetNodeTaskStatusResponseStatus` types `status` as the closed union
73
111
  // "running" | "stopped", but the runtime validator is `S.String` (distilled-proxmox's
@@ -113,24 +151,45 @@ describe('Proxmox.Vm distilled transport', () => {
113
151
  expect(fake.writes()).toEqual([]);
114
152
  });
115
153
 
116
- test('drift updates the config route and the next plan is a no-op', async () => {
117
- let current: unknown = { ...live, memory: '256' };
154
+ test('drift on a declared field updates the config route, and only that field', async () => {
155
+ // ⛔ THE "5-DEFAULT PUT" REGRESSION TEST. `name` is deliberately left UNDECLARED and deliberately
156
+ // NOT what qemu-form.ts's old default would have produced (`vm150`) -- the old `shape()` sent
157
+ // it (and `cores`/`sockets`/`onboot`) on every write regardless, silently resetting an
158
+ // undeclared field to its factory value. Only `memory` is declared here, so only `memory`
159
+ // may appear in the PUT body.
160
+ const declared = { ...props, memory: 512 };
161
+ let current: unknown = { ...live, memory: '256', name: 'a-name-nobody-declared' };
118
162
  const fake = fakePve((call) => {
119
163
  if (call.method === 'GET') return current;
120
- current = live;
164
+ current = { ...(current as Record<string, unknown>), memory: '512' };
121
165
  return null;
122
166
  });
123
167
  await withoutBao(async () => {
124
168
  const stack = engine(fake);
125
- expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
126
- diff: 'update',
127
- });
128
- await stack.deploy(ProxmoxVm('row', props));
129
- expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
130
- diff: 'noop',
131
- });
169
+ expect((await stack.verify(ProxmoxVm('row', declared), { all: true })).rows[0]).toMatchObject(
170
+ { diff: 'update' },
171
+ );
172
+ await stack.deploy(ProxmoxVm('row', declared));
173
+ expect((await stack.verify(ProxmoxVm('row', declared), { all: true })).rows[0]).toMatchObject(
174
+ { diff: 'noop' },
175
+ );
132
176
  });
133
177
  expect(fake.writes()).toEqual([`PUT nodes/${NODE}/qemu/${String(VMID)}/config`]);
178
+ const put = fake.calls.find((call) => call.method === 'PUT');
179
+ expect(put?.form).toEqual({ memory: '512' });
180
+ });
181
+
182
+ test('an unmanaged key smuggled past the types is refused before any write', async () => {
183
+ const fake = fakePve((call) => (call.method === 'GET' ? live : null));
184
+ // ⛔ `cipassword` IS TYPED `never` (qemu-props.ts): a cast is the only way in, exactly the
185
+ // scenario the type cannot guard against by itself. `formRefusals` is the runtime backstop.
186
+ const smuggled = { ...props, cipassword: 'not-a-real-secret' } as unknown as VmProps;
187
+ await withoutBao(async () => {
188
+ await expect(engine(fake).deploy(ProxmoxVm('row', smuggled))).rejects.toThrow(
189
+ /cipassword: not a config key this resource manages/,
190
+ );
191
+ });
192
+ expect(fake.writes()).toEqual([]);
134
193
  });
135
194
 
136
195
  test('unrelated read failures and a digest-less body do not write', async () => {
@@ -3,50 +3,56 @@
3
3
  * ⛔ GET and PUT use `/nodes/{node}/qemu/{vmid}/config`. DELETE uses `/nodes/{node}/qemu/{vmid}`.
4
4
  * qemu-server Qemu.pm names that second route `destroy_vm`. Sending DELETE to the config
5
5
  * route does not destroy the guest.
6
+ *
7
+ * ⛔ WRITES GO THROUGH `qemu-judge.ts`, NOT A WHOLE-FORM PUT (2026-09-26, red-team C1/C2/I1 on
8
+ * PR 297). `judge` decides per key, on the live volume id and live MAC (qemu-volume.ts,
9
+ * qemu-net.ts); only drifted keys are sent. `ownedRead`/`refuseTakeover` (C2) refuse to adopt or
10
+ * write a live VM this stack holds no state for, unless `--adopt` / `adopt(true)` says so —
11
+ * ownership.md's rule, ported here the way openbao/policy.ts already does it. `identityRefusal`
12
+ * (I1) refuses a changed vmid rather than building a second VM.
6
13
  */
7
14
  import { isResolved } from 'alchemy/Diff';
8
15
  import type { Input } from 'alchemy/Input';
9
16
  import * as nodes from '@distilled.cloud/proxmox/nodes';
10
17
  import * as Effect from 'effect/Effect';
18
+ import { refuseTakeover } from '../ownership/adopt.ts';
19
+ import { ownedRead } from '../ownership/probe.ts';
11
20
  import { runPve } from './distilled-pve.ts';
12
- import { createForm, shape } from './qemu-form.ts';
21
+ import { createForm, formRefusals, updateForm } from './qemu-form.ts';
13
22
  import { QemuRefusedError } from './qemu-errors.ts';
23
+ import { identityRefusal, judge } from './qemu-judge.ts';
24
+ import {
25
+ type VmAttributes,
26
+ type VmProps,
27
+ declaredKeys,
28
+ declaredValue,
29
+ isManagedKey,
30
+ storedConfig,
31
+ wireValue,
32
+ } from './qemu-props.ts';
14
33
  import { readVm } from './qemu-read.ts';
15
34
  import { qemuTask } from './qemu-task.ts';
16
- import type { VmAttributes, VmProps } from './qemu.ts';
17
35
  import type { PveSpec } from './resource-spec.ts';
18
36
  import { specGuards } from './resource-guard.ts';
19
- import { formToSend } from './update-guard.ts';
20
- import { int } from './values.ts';
21
37
 
22
38
  const CREATE_POLLS = 180;
23
39
  const DELETE_POLLS = 60;
24
40
 
25
- const attributesOf = (
26
- live: {
27
- cores?: unknown;
28
- memory?: unknown;
29
- name?: unknown;
30
- onboot?: unknown;
31
- sockets?: unknown;
32
- },
33
- props: VmProps,
34
- ): VmAttributes => ({
35
- cores: int(live.cores, 1),
36
- memory: int(live.memory, 512),
37
- name: typeof live.name === 'string' ? live.name : '',
41
+ const attributesOf = (live: Record<string, unknown>, props: VmProps): VmAttributes => ({
42
+ config: storedConfig(live),
38
43
  node: props.node,
39
- onboot: live.onboot === 1 || live.onboot === true,
40
- sockets: int(live.sockets, 1),
41
44
  vmid: props.vmid,
42
45
  });
43
46
 
47
+ /**
48
+ * ⚠️ KEPT ONLY TO SATISFY `PveSpec`'S REQUIRED FIELD — `qemu-judge.ts`'s `judge` is what diff and
49
+ * reconcile actually decide by, since a plain string compare is exactly the bug C1 found (it
50
+ * never matches the "new disk" or MAC-less spellings a declaration may use).
51
+ */
44
52
  const matches = (attributes: VmAttributes, props: VmProps) =>
45
- attributes.name === (props.name ?? `vm${String(props.vmid)}`) &&
46
- attributes.memory === (props.memory ?? 512) &&
47
- attributes.cores === (props.cores ?? 1) &&
48
- attributes.sockets === (props.sockets ?? 1) &&
49
- attributes.onboot === (props.onboot === true);
53
+ declaredKeys(props)
54
+ .filter(isManagedKey)
55
+ .every((key) => attributes.config[key] === wireValue(declaredValue(props, key)));
50
56
 
51
57
  const spec = {
52
58
  attributes: attributesOf,
@@ -58,14 +64,24 @@ const spec = {
58
64
  },
59
65
  matches,
60
66
  path: (props: VmProps) => `nodes/${props.node}/qemu/${String(props.vmid)}/config`,
61
- updateForm: shape,
67
+ updateForm,
62
68
  } satisfies PveSpec<VmProps, VmAttributes>;
63
69
 
64
70
  const { guardCreate, guardUpdate } = specGuards(spec);
65
71
 
66
72
  export const readQemu = (props: VmProps) =>
67
73
  readVm(props).pipe(
68
- Effect.map((live) => (live === undefined ? undefined : attributesOf(live, props))),
74
+ Effect.map((live) =>
75
+ live === undefined
76
+ ? undefined
77
+ : // ⚠️ WIDENED ON PURPOSE. `GetNodeQemuConfigResponse` types only the keys pve-manager's
78
+ // apidoc names as SCALARS; it cannot express `scsi[n]`/`net[n]`/`ipconfig[n]` at all
79
+ // (qemu-props.ts's header), so it carries no index signature. `storedConfig` reads
80
+ // those keys anyway, because the ACTUAL wire response has them (protocol-http.ts's
81
+ // `mapKeys` passes an unrecognised key through undecoded, both directions) — this
82
+ // cast only tells TypeScript what runtime already knows.
83
+ attributesOf(live as unknown as Record<string, unknown>, props),
84
+ ),
69
85
  );
70
86
 
71
87
  /** No purge, no skiplock, no destroy-unreferenced-disks. A missing config is already gone. */
@@ -77,28 +93,95 @@ export const deleteQemu = (props: VmProps) =>
77
93
  DELETE_POLLS,
78
94
  ).pipe(Effect.catchTag('QemuConfigNotFound', () => Effect.void));
79
95
 
96
+ /**
97
+ * ★ ONE PLACE, RUN BEFORE EVERY DIFF AND EVERY RECONCILE. A key smuggled past `VmProps`'s types
98
+ * (a cast, a JS caller: `cipassword`, `machine`, `hookscript`) is refused here rather than
99
+ * silently dropped from the form or silently sent — the same rule `lxc-create-form.ts` enforces
100
+ * for `Proxmox.Lxc`, and the check `qemu-props.ts`'s `cipassword?: never`/`machine?: never`
101
+ * cannot make by itself once a caller casts around the type.
102
+ */
103
+ const refuseUnmanaged = (props: VmProps) => {
104
+ const refused = formRefusals(props);
105
+ return refused.length === 0 ? Effect.void : Effect.fail(new QemuRefusedError(refused.join('\n')));
106
+ };
107
+
108
+ const refuseChange = (change: { readonly refuse: readonly string[] }) =>
109
+ change.refuse.length === 0
110
+ ? Effect.void
111
+ : Effect.fail(new QemuRefusedError(change.refuse.join('\n')));
112
+
80
113
  export const qemuHandlers = {
81
114
  list: () => Effect.succeed([]),
82
- read: ({ olds }: { olds: VmProps }) => readQemu(olds),
115
+ /**
116
+ * ⛔ `Unowned` UNLESS STATE ALREADY VOUCHES FOR IT (C2). With no attributes this is Alchemy's
117
+ * adoption probe or the recovery read for an interrupted create (ownership/probe.ts); `settled`
118
+ * is the same `judge` reconcile uses, so "proven ours" and "reconcile would write nothing" are
119
+ * the same question asked once.
120
+ */
121
+ read: Effect.fn(function* ({
122
+ fqn,
123
+ instanceId,
124
+ olds,
125
+ output,
126
+ }: {
127
+ fqn: string;
128
+ instanceId: string;
129
+ olds: VmProps;
130
+ output: VmAttributes | undefined;
131
+ }) {
132
+ const found = yield* readQemu(olds);
133
+ const settled = Effect.sync(
134
+ () => found !== undefined && judge(olds, found.config).drift.length === 0,
135
+ );
136
+ return yield* ownedRead({ fqn, instanceId, output }, found, settled);
137
+ }),
83
138
  diff: Effect.fn(function* ({
84
139
  news,
140
+ olds,
85
141
  output,
86
142
  }: {
87
143
  news: Input<VmProps>;
144
+ olds: VmProps;
88
145
  output: VmAttributes | undefined;
89
146
  }) {
90
147
  if (!isResolved(news)) return undefined;
148
+ const identity = identityRefusal(news.vmid, output?.vmid ?? olds.vmid);
149
+ if (identity !== undefined) return yield* Effect.fail(new QemuRefusedError(identity));
150
+ yield* refuseUnmanaged(news);
91
151
  yield* guardCreate(news, output === undefined);
92
152
  yield* guardUpdate(news);
93
153
  if (output === undefined) return undefined;
94
154
  const live = yield* readQemu(news);
95
155
  if (live === undefined) {
156
+ // ⚠️ `update`, not `create`: state exists and the VM does not. reconcile rebuilds it from the
157
+ // declaration -- said here too, so an operator reading the plan sees it before the deploy.
158
+ yield* Effect.logWarning(
159
+ `Proxmox.Vm ${news.node}/${String(news.vmid)}: the cluster lists no VM with this vmid -- ` +
160
+ 'a deploy CREATES it again, with new, empty volumes.',
161
+ );
96
162
  yield* guardCreate(news, true);
97
163
  return { action: 'update' } as const;
98
164
  }
99
- return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
165
+ const change = judge(news, live.config);
166
+ yield* refuseChange(change);
167
+ return { action: change.drift.length === 0 ? 'noop' : 'update' } as const;
100
168
  }),
101
- reconcile: Effect.fn(function* ({ news }: { news: VmProps }) {
169
+ reconcile: Effect.fn(function* ({
170
+ fqn,
171
+ instanceId,
172
+ news,
173
+ olds,
174
+ output,
175
+ }: {
176
+ fqn: string;
177
+ instanceId: string;
178
+ news: VmProps;
179
+ olds: VmProps | undefined;
180
+ output: VmAttributes | undefined;
181
+ }) {
182
+ const identity = identityRefusal(news.vmid, output?.vmid ?? olds?.vmid);
183
+ if (identity !== undefined) return yield* Effect.fail(new QemuRefusedError(identity));
184
+ yield* refuseUnmanaged(news);
102
185
  const live = yield* readQemu(news);
103
186
  yield* guardCreate(news, live === undefined);
104
187
  yield* guardUpdate(news);
@@ -110,13 +193,20 @@ export const qemuHandlers = {
110
193
  CREATE_POLLS,
111
194
  );
112
195
  } else {
113
- const form = formToSend(spec.matches, live, news, spec.updateForm(news));
114
- if (form !== undefined) {
196
+ // ⛔ C2: a live VM this stack holds no state for is refused unless adoption is on — dies
197
+ // (never returns) for a fresh create/replace generation; a no-op otherwise (adopt.ts).
198
+ yield* refuseTakeover(
199
+ { fqn, instanceId, output },
200
+ `Proxmox.Vm VM ${String(news.vmid)} on ${news.node}`,
201
+ );
202
+ const change = judge(news, live.config);
203
+ yield* refuseChange(change);
204
+ if (Object.keys(change.put).length > 0) {
115
205
  yield* runPve(
116
206
  news.target,
117
207
  'provision',
118
208
  true,
119
- nodes.putNodeQemuConfig({ ...form, node: news.node, vmid: String(news.vmid) }),
209
+ nodes.putNodeQemuConfig({ ...change.put, node: news.node, vmid: String(news.vmid) }),
120
210
  );
121
211
  }
122
212
  }
@@ -128,6 +218,15 @@ export const qemuHandlers = {
128
218
  ),
129
219
  );
130
220
  }
221
+ const left = judge(news, after.config);
222
+ if (left.drift.length > 0) {
223
+ return yield* Effect.fail(
224
+ new QemuRefusedError(
225
+ `${spec.path(news)}: after the write, ${left.drift.join(', ')} still differ from the ` +
226
+ 'declaration. PVE may have normalised the value -- declare what it stores.',
227
+ ),
228
+ );
229
+ }
131
230
  return after;
132
231
  }),
133
232
  delete: ({ olds }: { olds: VmProps }) => deleteQemu(olds),
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Pure unit tests for `sameNet`/`netWrite` — the red-team C1 fix on PR 297. No network, no fake PVE.
3
+ * ⛔ WHAT THESE PIN: a MAC-less (or bare-model) declaration matches a live NIC with any MAC, and
4
+ * never generates a new one when written; an explicit MAC is compared and written as declared; a
5
+ * real drift elsewhere on the line (VLAN tag, bridge) still shows up.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { netWrite, sameNet } from './qemu-net.ts';
9
+
10
+ const LIVE = 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=20';
11
+
12
+ describe('a MAC-less declaration', () => {
13
+ test('bare model token matches a live NIC with any MAC: the measured C1 regression', () => {
14
+ expect(sameNet('virtio,bridge=vmbr0,tag=20', LIVE)).toBe(true);
15
+ });
16
+
17
+ test('empty-value spelling matches too', () => {
18
+ expect(sameNet('virtio=,bridge=vmbr0,tag=20', LIVE)).toBe(true);
19
+ });
20
+
21
+ test('writing it carries the LIVE mac forward, never regenerating one', () => {
22
+ expect(netWrite('virtio,bridge=vmbr0,tag=21', LIVE)).toBe(
23
+ 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=21',
24
+ );
25
+ });
26
+ });
27
+
28
+ describe('an explicit MAC', () => {
29
+ test('a matching MAC is a noop', () => {
30
+ expect(sameNet(LIVE, LIVE)).toBe(true);
31
+ });
32
+
33
+ test('a different MAC is real drift and is written as declared', () => {
34
+ const declared = 'virtio=11:22:33:44:55:66,bridge=vmbr0,tag=20';
35
+ expect(sameNet(declared, LIVE)).toBe(false);
36
+ expect(netWrite(declared, LIVE)).toBe(declared);
37
+ });
38
+ });
39
+
40
+ describe('a real change elsewhere on the line', () => {
41
+ test('a different VLAN tag is drift even with the MAC substituted', () => {
42
+ expect(sameNet('virtio,bridge=vmbr0,tag=42', LIVE)).toBe(false);
43
+ });
44
+
45
+ test('a different bridge is drift', () => {
46
+ expect(sameNet('virtio,bridge=vmbr1,tag=20', LIVE)).toBe(false);
47
+ });
48
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `netN`: comparing a declared NIC against a live one without regenerating its MAC every deploy.
3
+ *
4
+ * ★ PORTED FROM lxc-wire.ts's `netMaps`/`netWrite` (2026-09-26, red-team C1 on PR 297), adapted for
5
+ * QEMU's spelling. A container's `net0` carries a `hwaddr=` sub-key; a VM's carries the MAC as the
6
+ * VALUE of whichever NIC-model key is present — `virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0` — with the
7
+ * model itself (`e1000`, `virtio`, …) chosen by pve-qemu-server's own model list, not a fixed key
8
+ * name. A declaration with no MAC spells the model bare (`virtio,bridge=vmbr0,tag=20`) or with an
9
+ * empty value (`virtio=,bridge=vmbr0`); PVE generates one on create either way.
10
+ * ⛔ WITHOUT THIS, A PUT OF AN UNCHANGED NIC GENERATES A NEW MAC — a new DHCP lease and a new IPv6
11
+ * link-local address for a guest that only had, say, its VLAN tag edited. Measured by the
12
+ * builder's red team against a MAC-less `net0` declaration.
13
+ * ⚠️ OTHER SUB-KEYS (`firewall`, `link_down`, `mtu`, `queues`, `rate`, `tag`, `trunks`) COMPARE
14
+ * RAW, NO DEFAULTS DROPPED — that table is unverified here, unlike lxc-wire.ts's `DEFAULTS`. A
15
+ * difference in one is real drift and gets written, exactly as the whole-string comparison this
16
+ * replaces already did; only the MAC is special-cased, the minimum that fixes the measured bug.
17
+ */
18
+ /** pve-qemu-server's own `$nic_model_list` — whichever of these is present is the MAC-bearing key. */
19
+ const NIC_MODELS = [
20
+ 'e1000',
21
+ 'e1000e',
22
+ 'i82551',
23
+ 'i82557b',
24
+ 'i82559er',
25
+ 'i82562',
26
+ 'i82801',
27
+ 'ne2k_isa',
28
+ 'ne2k_pci',
29
+ 'pcnet',
30
+ 'rtl8139',
31
+ 'virtio',
32
+ 'vmxnet3',
33
+ ] as const;
34
+
35
+ /** `a=1,b=2` or a bare `a` (→ `['a', '']`) — the one-off parser this file needs, not lxc-wire's. */
36
+ const netPairs = (value: string): Map<string, string> =>
37
+ new Map(
38
+ value
39
+ .split(',')
40
+ .map((part) => part.trim())
41
+ .filter((part) => part !== '')
42
+ .map((part): [string, string] => {
43
+ const at = part.indexOf('=');
44
+ return at === -1 ? [part, ''] : [part.slice(0, at), part.slice(at + 1)];
45
+ }),
46
+ );
47
+
48
+ const modelKey = (map: ReadonlyMap<string, string>): string | undefined =>
49
+ NIC_MODELS.find((model) => map.has(model));
50
+
51
+ /** The MAC-substituted comparison maps: an undeclared/blank MAC is PVE's, not drift. */
52
+ export const netMaps = (declared: string, live: string) => {
53
+ const want = netPairs(declared);
54
+ const have = netPairs(live);
55
+ const wantModel = modelKey(want);
56
+ const haveModel = modelKey(have);
57
+ if (wantModel !== undefined && (want.get(wantModel) ?? '') === '' && haveModel !== undefined) {
58
+ want.set(wantModel, have.get(haveModel) ?? '');
59
+ }
60
+ return { have, want };
61
+ };
62
+
63
+ export const sameNet = (declared: string, live: string): boolean => {
64
+ const { have, want } = netMaps(declared, live);
65
+ return have.size === want.size && [...want].every(([k, v]) => have.get(k) === v);
66
+ };
67
+
68
+ /** The value to PUT: the declaration, plus the live MAC when it names none. */
69
+ export const netWrite = (declared: string, live: string): string => {
70
+ const want = netPairs(declared);
71
+ const wantModel = modelKey(want);
72
+ const have = netPairs(live);
73
+ const haveModel = modelKey(have);
74
+ if (wantModel !== undefined && (want.get(wantModel) ?? '') === '' && haveModel !== undefined) {
75
+ want.set(wantModel, have.get(haveModel) ?? '');
76
+ }
77
+ return [...want].map(([k, v]) => (v === '' ? k : `${k}=${v}`)).join(',');
78
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Whose VM it is — docs/ownership.md applied to `Proxmox.Vm` (red-team C2 on PR 297), through the
3
+ * real engine over a stub fetch (fake-pve.ts).
4
+ *
5
+ * ⛔ WHAT THIS PINS: a live VM this stack holds no state for is never adopted or written silently
6
+ * (the measured regression: `adopt(false)` on an existing VM still planned `adopted` and PUT it).
7
+ * `adopt(false)` on the declaration wins over the test harness's own forced `AdoptPolicy: true`
8
+ * (fake-engine.ts) — a resource-scoped `adopt(...)` overrides the ambient policy both ways
9
+ * (ownership/adopt.ts), which is what lets this test observe the refusal at all.
10
+ */
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { adopt } from 'alchemy/AdoptPolicy';
13
+ import * as Layer from 'effect/Layer';
14
+ import { engineOver } from '../verify/fake-engine.ts';
15
+ import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
16
+ import { ProxmoxVm, ProxmoxVmProvider } from './qemu.ts';
17
+
18
+ const NODE = 'pve1';
19
+ const VMID = 150;
20
+ const props = { node: NODE, target: FAKE_TARGET, vmid: VMID };
21
+ const live = { digest: 'a'.repeat(40), memory: '512' };
22
+
23
+ const engine = (fake: ReturnType<typeof fakePve>) =>
24
+ engineOver(ProxmoxVmProvider().pipe(Layer.provideMerge(fake.layer)));
25
+
26
+ describe('Proxmox.Vm ownership', () => {
27
+ test('adopt(false): a live VM with no state is refused, and nothing is written', async () => {
28
+ const fake = fakePve((call) => (call.method === 'GET' ? live : null));
29
+ await withoutBao(async () => {
30
+ await expect(engine(fake).deploy(ProxmoxVm('row', props).pipe(adopt(false)))).rejects.toThrow(
31
+ /adopt|exists, and this stack holds no state/i,
32
+ );
33
+ });
34
+ expect(fake.writes()).toEqual([]);
35
+ });
36
+
37
+ test('the harness default (adoption on) still adopts with no write — unchanged behaviour', async () => {
38
+ const fake = fakePve((call) => (call.method === 'GET' ? live : null));
39
+ await withoutBao(async () => {
40
+ expect(await engine(fake).deploy(ProxmoxVm('row', props))).toEqual({ row: 'adopted' });
41
+ });
42
+ expect(fake.writes()).toEqual([]);
43
+ });
44
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Pure unit tests for the declared-keys model: no network, no fake PVE. The engine-level proof
3
+ * that an unmanaged key is refused before any write lives in qemu-lifecycle.test.ts, alongside
4
+ * the node-pinned-refusal and 5-default-PUT regression tests.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { formRefusals } from './qemu-form.ts';
8
+ import {
9
+ type VmProps,
10
+ declaredKeys,
11
+ indexedKey,
12
+ isManagedKey,
13
+ storedConfig,
14
+ wireValue,
15
+ } from './qemu-props.ts';
16
+ import { FAKE_TARGET } from './fake-pve.ts';
17
+
18
+ const base = { node: 'pve1', target: FAKE_TARGET, vmid: 150 } as const;
19
+
20
+ describe('declaredKeys', () => {
21
+ test('excludes identity fields and undeclared ones, and sorts what is left', () => {
22
+ const props: VmProps = { ...base, cores: 2, memory: 4096 };
23
+ expect(declaredKeys(props)).toEqual(['cores', 'memory']);
24
+ });
25
+
26
+ test('an empty declaration manages nothing — the base for every "adopt as-is" test', () => {
27
+ expect(declaredKeys(base as unknown as VmProps)).toEqual([]);
28
+ });
29
+ });
30
+
31
+ describe('isManagedKey / indexedKey', () => {
32
+ test('the scalar and indexed families Talos needs are all managed', () => {
33
+ for (const key of [
34
+ 'agent',
35
+ 'boot',
36
+ 'cpu',
37
+ 'memory',
38
+ 'scsihw',
39
+ 'scsi0',
40
+ 'net0',
41
+ 'ide2',
42
+ 'ipconfig0',
43
+ 'serial0',
44
+ ]) {
45
+ expect(isManagedKey(key)).toBe(true);
46
+ }
47
+ });
48
+
49
+ test('⛔ no secret prop exists: cipassword and machine are never managed keys', () => {
50
+ expect(isManagedKey('cipassword')).toBe(false);
51
+ expect(isManagedKey('machine')).toBe(false);
52
+ });
53
+
54
+ test('an out-of-range index is not a PVE key this resource recognises', () => {
55
+ expect(indexedKey('scsi31')).toBeUndefined(); // pve-qemu-server's own 0-30
56
+ expect(indexedKey('ide4')).toBeUndefined(); // 0-3
57
+ expect(indexedKey('scsi03')).toBeUndefined(); // not how PVE spells it
58
+ });
59
+ });
60
+
61
+ describe('wireValue / storedConfig', () => {
62
+ test('booleans go as 1/0, everything else as String()', () => {
63
+ expect(wireValue(true)).toBe('1');
64
+ expect(wireValue(false)).toBe('0');
65
+ expect(wireValue(4096)).toBe('4096');
66
+ expect(wireValue('local-zfs:32')).toBe('local-zfs:32');
67
+ });
68
+
69
+ test('storedConfig is an allowlist: an unmanaged live key never reaches state', () => {
70
+ const live = { cipassword: 'leaked', cores: 2, digest: 'a'.repeat(40), scsi0: 'local:32' };
71
+ expect(storedConfig(live)).toEqual({ cores: '2', scsi0: 'local:32' });
72
+ });
73
+ });
74
+
75
+ describe('formRefusals — the runtime backstop behind the never-typed props', () => {
76
+ test('a clean declaration is refused nothing', () => {
77
+ expect(formRefusals({ ...base, agent: true, net0: 'bridge=vmbr0,tag=42' })).toEqual([]);
78
+ });
79
+
80
+ test('a key smuggled past the types is named, not silently dropped or silently sent', () => {
81
+ const smuggled = { ...base, cipassword: 'x', machine: 'q35' } as unknown as VmProps;
82
+ expect(formRefusals(smuggled)).toEqual([
83
+ 'cipassword: not a config key this resource manages (vmid 150).',
84
+ 'machine: not a config key this resource manages (vmid 150).',
85
+ ]);
86
+ });
87
+ });