@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
@@ -0,0 +1,189 @@
1
+ /**
2
+ * `Talos.MachineConfig` acceptance tests from docs/plans/2026-09-26-talos-secrets-flow.md — fully
3
+ * offline, fake `bao`/`talosctl` (fake-process.ts via machine-config-fixtures.ts), no real
4
+ * process ever spawns. Bounded-poll-per-mode tests live in machine-config-poll.test.ts.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { existsSync, statSync } from 'node:fs';
8
+ import { describe, it } from 'node:test';
9
+ import { Unowned } from 'alchemy/AdoptPolicy';
10
+ import {
11
+ CONFIG_TEXT,
12
+ PIN,
13
+ dispatcher,
14
+ hasTag,
15
+ props,
16
+ run,
17
+ wrapperFor,
18
+ } from './machine-config-fixtures.ts';
19
+ import type { FakeCall } from './fake-process.ts';
20
+ import { diffMachineConfig, readMachineConfig } from './machine-config-read.ts';
21
+ import { type MachineConfigAttributes, reconcileMachineConfig } from './talos-machine-config.ts';
22
+ import { extractMachineConfigSpec } from './values.ts';
23
+
24
+ describe('reconcileMachineConfig — digest gate', () => {
25
+ it('fails closed on a mismatch and never spawns talosctl', async () => {
26
+ const calls: FakeCall[] = [];
27
+ await assert.rejects(
28
+ run(
29
+ reconcileMachineConfig(props(), undefined),
30
+ dispatcher({ configText: 'not the pinned text' }),
31
+ calls,
32
+ ),
33
+ hasTag('TalosConfigDigestMismatch'),
34
+ );
35
+ assert.ok(
36
+ calls.every((c) => c.command !== 'talosctl'),
37
+ 'no talosctl call on a digest mismatch',
38
+ );
39
+ });
40
+ });
41
+
42
+ describe('reconcileMachineConfig — apply argv and --insecure', () => {
43
+ it('CREATE (output undefined) passes --insecure; argv never carries --dry-run', async () => {
44
+ const calls: FakeCall[] = [];
45
+ await run(reconcileMachineConfig(props(), undefined), dispatcher({}), calls);
46
+ const apply = calls.find((c) => c.command === 'talosctl' && c.args[0] === 'apply-config');
47
+ assert.ok(apply);
48
+ assert.ok(apply.args.includes('--insecure'));
49
+ assert.ok(!calls.some((c) => c.args.includes('--dry-run')));
50
+ });
51
+
52
+ it('UPDATE (output defined) never passes --insecure; base argv is exactly apply-config/--file/--mode', async () => {
53
+ const calls: FakeCall[] = [];
54
+ const output: MachineConfigAttributes = {
55
+ configDigest: PIN,
56
+ converged: 'read-back',
57
+ mode: 'auto',
58
+ node: props().node,
59
+ };
60
+ await run(reconcileMachineConfig(props(), output), dispatcher({}), calls);
61
+ const apply = calls.find((c) => c.command === 'talosctl' && c.args[0] === 'apply-config');
62
+ assert.ok(apply);
63
+ assert.ok(!apply.args.includes('--insecure'));
64
+ const filePath = apply.args[apply.args.indexOf('--file') + 1];
65
+ assert.deepEqual(apply.args.slice(0, 5), [
66
+ 'apply-config',
67
+ '--file',
68
+ filePath,
69
+ '--mode',
70
+ 'auto',
71
+ ]);
72
+ });
73
+
74
+ it('the machine-config temp file exists (0600) exactly when talosctl spawns, and is gone after', async () => {
75
+ let seenAtSpawn: { existed: boolean; mode: number } | undefined;
76
+ let path = '';
77
+ const handler = (call: FakeCall) => {
78
+ if (call.command === 'talosctl' && call.args[0] === 'apply-config') {
79
+ path = call.args[call.args.indexOf('--file') + 1] ?? '';
80
+ seenAtSpawn = { existed: existsSync(path), mode: statSync(path).mode & 0o777 };
81
+ }
82
+ return dispatcher({})(call);
83
+ };
84
+ await run(reconcileMachineConfig(props(), undefined), handler);
85
+ assert.ok(seenAtSpawn?.existed, 'temp file must exist the instant talosctl spawns');
86
+ assert.equal(seenAtSpawn?.mode, 0o600);
87
+ assert.equal(existsSync(path), false, 'temp file must be gone once reconcile returns');
88
+ });
89
+ });
90
+
91
+ describe('readMachineConfig — extracted spec only, no swallowing', () => {
92
+ it('converges even when wrapper metadata differs, since only spec is hashed', async () => {
93
+ const result = await run(
94
+ readMachineConfig(props()),
95
+ dispatcher({ liveWrapper: () => ({ stdout: wrapperFor(CONFIG_TEXT, '999') }) }),
96
+ );
97
+ assert.ok(result);
98
+ assert.equal(result.converged, 'read-back');
99
+ });
100
+
101
+ it('reports false (not an error), branded Unowned, when a successful read genuinely differs', async () => {
102
+ const result = await run(
103
+ readMachineConfig(props()),
104
+ dispatcher({
105
+ liveWrapper: () => ({ stdout: wrapperFor('machine:\n type: controlplane\n') }),
106
+ }),
107
+ );
108
+ assert.ok(result);
109
+ assert.equal(result.converged, false);
110
+ // ⛔ fix-first #1c — a digest mismatch on the engine's cold-start adoption probe must never
111
+ // read as plain (silently-owned) attrs, or a mistyped node pointing at a sibling row's
112
+ // configured node gets `apply-config` run onto it with no `adopt()` in sight.
113
+ assert.equal(Unowned.is(result), true);
114
+ });
115
+
116
+ it('propagates the AUTHENTICATED failure when the maintenance probe also fails, never converged: false', async () => {
117
+ await assert.rejects(
118
+ run(
119
+ readMachineConfig(props()),
120
+ dispatcher({ liveWrapper: () => ({ exitCode: 1, stderr: 'unreachable' }) }),
121
+ ),
122
+ (error: unknown) => error instanceof Error && error.message.includes('unreachable'),
123
+ );
124
+ });
125
+ });
126
+
127
+ describe('readMachineConfig — cold-start adoption answer (fix-first #1)', () => {
128
+ it('returns undefined (not created) when the authenticated read fails but the node answers insecurely', async () => {
129
+ const result = await run(
130
+ readMachineConfig(props()),
131
+ dispatcher({
132
+ liveWrapper: () => ({
133
+ exitCode: 1,
134
+ stderr: 'x509: certificate signed by unknown authority',
135
+ }),
136
+ maintenanceReachable: true,
137
+ }),
138
+ );
139
+ assert.equal(result, undefined);
140
+ });
141
+ });
142
+
143
+ describe('diffMachineConfig', () => {
144
+ it('plans noop when the live digest matches, update when it does not', async () => {
145
+ const output: MachineConfigAttributes = {
146
+ configDigest: PIN,
147
+ converged: 'read-back',
148
+ mode: 'auto',
149
+ node: props().node,
150
+ };
151
+ const noop = await run(diffMachineConfig(props(), output), dispatcher({}));
152
+ assert.equal(noop?.action, 'noop');
153
+ const update = await run(
154
+ diffMachineConfig(props(), output),
155
+ dispatcher({
156
+ liveWrapper: () => ({ stdout: wrapperFor('machine:\n type: controlplane\n') }),
157
+ }),
158
+ );
159
+ assert.equal(update?.action, 'update');
160
+ });
161
+
162
+ it('plans update when the live resource is gone (node reverted to maintenance mode)', async () => {
163
+ const output: MachineConfigAttributes = {
164
+ configDigest: PIN,
165
+ converged: 'read-back',
166
+ mode: 'auto',
167
+ node: props().node,
168
+ };
169
+ const result = await run(
170
+ diffMachineConfig(props(), output),
171
+ dispatcher({
172
+ liveWrapper: () => ({
173
+ exitCode: 1,
174
+ stderr: 'x509: certificate signed by unknown authority',
175
+ }),
176
+ maintenanceReachable: true,
177
+ }),
178
+ );
179
+ assert.equal(result?.action, 'update');
180
+ });
181
+ });
182
+
183
+ // Sanity: the wrapper fixture itself round-trips through extractMachineConfigSpec (belt & suspenders
184
+ // with values.test.ts, which owns the exhaustive shape tests).
185
+ describe('fixture sanity', () => {
186
+ it('wrapperFor embeds exactly CONFIG_TEXT under spec', () => {
187
+ assert.equal(extractMachineConfigSpec(wrapperFor(CONFIG_TEXT)), CONFIG_TEXT);
188
+ });
189
+ });
@@ -2,38 +2,51 @@
2
2
  * `Talos.MachineConfig` — apply one machine configuration document to one node.
3
3
  *
4
4
  * ★ REASONED FROM talosctl apply-config (Talos v1.13 CLI reference): `-f/--file`, `-n/--nodes`,
5
- * `-m/--mode`, `-i/--insecure` for maintenance mode, `--dry-run` for planning without a write.
5
+ * `-m/--mode`, `-i/--insecure` for maintenance mode.
6
6
  *
7
- * ⛔ THE CONFIG BODY IS NEVER AN ATTRIBUTE. Machine configs can embed cluster tokens; only a digest
8
- * of the repo file is persisted. Read `configFile` from disk at reconcile time.
7
+ * ⛔ THE CONFIG BODY IS NEVER AN ATTRIBUTE, A PROP, OR ARGV — ONLY ITS DIGEST. K-A3
8
+ * (docs/plans/2026-09-26-talos-secrets-flow.md, option O-A) moved the source of truth from a
9
+ * repo file (the shipped `configFile` + `STACK_DIR`, gone with this rewrite) to OpenBao: props
10
+ * carry a `configKey` (a vault path) and a `configDigest` PINNED IN GIT, never the bytes. The
11
+ * source is write-only from Alchemy's point of view — content is read once per reconcile,
12
+ * verified against the pin, written to a session-temp file, and never returned from any
13
+ * handler.
14
+ * ⛔ NEVER `--dry-run`. Talos's dry-run prints a full, unredacted old/new config diff — with an
15
+ * empty old config (the first apply) that diff IS the whole config, CA private key and
16
+ * bootstrap token included (REASONED, `internal/app/machined/.../v1alpha1_server.go:258-272` +
17
+ * `configdiff.go`; docs/plans/2026-09-26-talos-stack-first-boot.md). This file never builds
18
+ * that flag.
19
+ * ⛔ `insecure` IS NOT A PROP. A fixed value fails in both directions — `true` breaks every later
20
+ * update, `false` breaks the very first apply against a node with no talosconfig auth yet — so
21
+ * CREATE (`output === undefined`) applies `--insecure` and UPDATE never does (resource.ts).
9
22
  */
10
23
  import { Resource } from 'alchemy';
11
- import { isResolved } from 'alchemy/Diff';
12
24
  import type { Input } from 'alchemy/Input';
13
25
  import * as Provider from 'alchemy/Provider';
14
26
  import * as Effect from 'effect/Effect';
15
- import { mintTalosconfig } from './credentials.ts';
16
- import type { TalosRequirements, WithTarget } from './resource.ts';
27
+ import { mintKvTempFile, mintTalosconfig, readKvValue } from './credentials.ts';
28
+ import { diffMachineConfig, liveSpecDigest, readMachineConfig } from './machine-config-read.ts';
29
+ import { type PollPolicy, confirmConverged } from './machine-config-poll.ts';
30
+ import type { ApplyMode, TalosRequirements, WithTarget } from './resource.ts';
31
+ import { TalosConfigDigestMismatch } from './talos-errors.ts';
17
32
  import { talosctl } from './talosctl.ts';
18
- import { configDigest, resolveConfigPath } from './values.ts';
33
+ import { configDigest } from './values.ts';
19
34
 
20
- const STACK_DIR = new URL('..', import.meta.url).pathname;
21
-
22
- export type ApplyMode = 'auto' | 'no-reboot' | 'reboot' | 'staged' | 'try';
35
+ export type { ApplyMode } from './resource.ts';
23
36
 
24
37
  export interface MachineConfigProps extends WithTarget {
25
38
  /** Node IP or hostname — the `-n` target. */
26
39
  node: string;
27
- /** Repo-relative or absolute path to the YAML file passed to `-f`. */
28
- configFile: string;
29
- /** talosctl `-m` mode. Default `auto` per published CLI. */
30
- mode?: ApplyMode;
40
+ /** OpenBao KV path under `target.mount` holding this node's rendered config, e.g. `nodes/10001`. */
41
+ configKey: string;
31
42
  /**
32
- * Maintenance-mode apply only (`-i`). Default false — post-bootstrap applies use talosconfig auth.
33
- *
34
- * ⚠️ REASONED: docs describe `--insecure` for the first apply before talosconfig exists.
43
+ * sha256(canonicalText(content)) of the seeded KV value (values.ts `configDigest`), pinned in
44
+ * git by the operator. ⛔ Verified against the live KV content before ANY talosctl spawn — a
45
+ * mismatch fails closed (`TalosConfigDigestMismatch`), nothing is applied.
35
46
  */
36
- insecure?: boolean;
47
+ configDigest: string;
48
+ /** talosctl `-m` mode. Default `auto` per published CLI. */
49
+ mode?: ApplyMode;
37
50
  /** Ordering edge — e.g. wait for Proxmox.Vm. */
38
51
  after?: readonly unknown[];
39
52
  }
@@ -42,8 +55,13 @@ export interface MachineConfigAttributes {
42
55
  node: string;
43
56
  configDigest: string;
44
57
  mode: ApplyMode;
45
- /** True when the node's reported config digest matches the declared file. */
46
- converged: boolean;
58
+ /**
59
+ * Which convergence claim was made. `'read-back'`/`'accepted'` only come from `reconcile` (see
60
+ * machine-config-poll.ts); `false` only comes from `read`, meaning a live digest was
61
+ * successfully fetched and genuinely differs — never a stand-in for a failed read, which
62
+ * propagates as its own error instead (docs/plans/2026-09-26-talos-stack-first-boot.md).
63
+ */
64
+ converged: 'read-back' | 'accepted' | false;
47
65
  }
48
66
 
49
67
  export interface TalosMachineConfig extends Resource<
@@ -56,77 +74,67 @@ export interface TalosMachineConfig extends Resource<
56
74
 
57
75
  export const TalosMachineConfig = Resource<TalosMachineConfig>('Talos.MachineConfig');
58
76
 
59
- const loadDigest = (props: MachineConfigProps) =>
60
- Effect.gen(function* () {
61
- const path = resolveConfigPath(STACK_DIR, props.configFile);
62
- const text = yield* Effect.tryPromise({
63
- try: () => Bun.file(path).text(),
64
- catch: (cause) => new Error(`reading ${props.configFile}: ${String(cause)}`),
65
- });
66
- return configDigest(text);
67
- });
77
+ /**
78
+ * The read path (`readMachineConfig`, `diffMachineConfig`, `liveSpecDigest`) lives in
79
+ * machine-config-read.ts — see that file's own header for the ownership-aware three-way answer
80
+ * `read` now gives (fix-first #1/#2, PR 307 red team).
81
+ */
82
+
83
+ /** `pollPolicies` is a test seam only — see machine-config-poll.ts's `confirmConverged`. */
84
+ export const reconcileMachineConfig = (
85
+ props: MachineConfigProps,
86
+ output: MachineConfigAttributes | undefined,
87
+ pollPolicies?: { readonly immediate: PollPolicy; readonly rebootish: PollPolicy },
88
+ ) =>
89
+ Effect.scoped(
90
+ Effect.gen(function* () {
91
+ const mode = props.mode ?? 'auto';
68
92
 
69
- /** REASONED: `talosctl get machineconfig -o yaml` returns the live document. */
70
- const liveDigest = (props: MachineConfigProps, talosconfigPath: string) =>
71
- talosctl(['get', 'machineconfig', '-o', 'yaml'], {
72
- nodes: [props.node],
73
- talosconfigPath,
74
- }).pipe(
75
- Effect.map((yaml) => configDigest(yaml)),
76
- Effect.orElseSucceed(() => undefined),
77
- );
93
+ // ⛔ VERIFY BEFORE ANY talosctl SPAWN. A mismatch fails closed — see talos-errors.ts.
94
+ const raw = yield* readKvValue(props.target.mount, props.configKey, ['config']);
95
+ const gotDigest = configDigest(raw);
96
+ if (gotDigest !== props.configDigest) {
97
+ return yield* Effect.fail(
98
+ new TalosConfigDigestMismatch({
99
+ expected: props.configDigest,
100
+ got: gotDigest,
101
+ key: props.configKey,
102
+ mount: props.target.mount,
103
+ }),
104
+ );
105
+ }
78
106
 
79
- const read = (props: MachineConfigProps) =>
80
- Effect.gen(function* () {
81
- const wanted = yield* loadDigest(props);
82
- const credential = yield* mintTalosconfig(props.target);
83
- const live = yield* liveDigest(props, credential.talosconfigPath);
84
- return {
85
- configDigest: wanted,
86
- converged: live === wanted,
87
- mode: props.mode ?? 'auto',
88
- node: props.node,
89
- };
90
- });
107
+ const credential = yield* mintTalosconfig(props.target);
108
+ const { path } = yield* mintKvTempFile(raw, `${props.target.cluster}-${props.node}-config`);
91
109
 
92
- const diff = (news: Input<MachineConfigProps>, output: MachineConfigAttributes | undefined) =>
93
- Effect.gen(function* () {
94
- if (output === undefined || !isResolved(news)) return undefined;
95
- const live = yield* read(news);
96
- if (live.converged) return { action: 'noop' } as const;
97
- return { action: 'update' } as const;
98
- });
110
+ const isCreate = output === undefined;
111
+ const args = [
112
+ 'apply-config',
113
+ '--file',
114
+ path,
115
+ '--mode',
116
+ mode,
117
+ ...(isCreate ? ['--insecure'] : []),
118
+ ];
119
+ yield* talosctl(args, { nodes: [props.node], talosconfigPath: credential.talosconfigPath });
99
120
 
100
- const reconcile = (props: MachineConfigProps) =>
101
- Effect.gen(function* () {
102
- const path = resolveConfigPath(STACK_DIR, props.configFile);
103
- const wanted = yield* loadDigest(props);
104
- const credential = yield* mintTalosconfig(props.target);
105
- const args = [
106
- 'apply-config',
107
- '--file',
108
- path,
109
- '--mode',
110
- props.mode ?? 'auto',
111
- ...(props.insecure === true ? ['--insecure'] : []),
112
- ];
113
- yield* talosctl(args, { nodes: [props.node], talosconfigPath: credential.talosconfigPath });
114
- const live = yield* liveDigest(props, credential.talosconfigPath);
115
- if (live !== wanted) {
116
- return yield* Effect.die(
117
- new Error(
118
- `${props.node}: apply-config returned no error but machineconfig digest still differs. ` +
119
- 'Read back rather than trusting the exit code alone.',
121
+ const converged = yield* confirmConverged(
122
+ props.node,
123
+ mode,
124
+ liveSpecDigest(props, credential.talosconfigPath).pipe(
125
+ Effect.map((live) => live === props.configDigest),
120
126
  ),
127
+ pollPolicies,
121
128
  );
122
- }
123
- return {
124
- configDigest: wanted,
125
- converged: true,
126
- mode: props.mode ?? 'auto',
127
- node: props.node,
128
- };
129
- });
129
+
130
+ return {
131
+ configDigest: props.configDigest,
132
+ converged,
133
+ mode,
134
+ node: props.node,
135
+ } satisfies MachineConfigAttributes;
136
+ }),
137
+ );
130
138
 
131
139
  const handlers = {
132
140
  delete: () => Effect.void,
@@ -136,10 +144,16 @@ const handlers = {
136
144
  }: {
137
145
  news: Input<MachineConfigProps>;
138
146
  output: MachineConfigAttributes | undefined;
139
- }) => diff(news, output),
147
+ }) => diffMachineConfig(news, output),
140
148
  list: () => Effect.succeed([]),
141
- read: ({ olds }: { olds: MachineConfigProps }) => read(olds),
142
- reconcile: ({ news }: { news: MachineConfigProps }) => reconcile(news),
149
+ read: ({ olds }: { olds: MachineConfigProps }) => readMachineConfig(olds),
150
+ reconcile: ({
151
+ news,
152
+ output,
153
+ }: {
154
+ news: MachineConfigProps;
155
+ output: MachineConfigAttributes | undefined;
156
+ }) => reconcileMachineConfig(news, output),
143
157
  };
144
158
 
145
159
  export const TalosMachineConfigProvider = () =>
@@ -30,6 +30,13 @@ export type TalosRunOptions = {
30
30
  readonly talosconfigPath: string;
31
31
  readonly nodes?: readonly string[];
32
32
  readonly endpoints?: readonly string[];
33
+ /**
34
+ * `-i/--insecure` — a maintenance-mode node has no established PKI yet. Used by the CREATE apply
35
+ * (resource.ts's own header) and by talos-machine-config.ts's maintenance-mode probe (fix-first
36
+ * #1, PR 307 red team): `--talosconfig` is still passed alongside it, matching the CREATE path,
37
+ * since insecure mode ignores the file's certs rather than requiring their absence.
38
+ */
39
+ readonly insecure?: boolean;
33
40
  };
34
41
 
35
42
  /** Run `talosctl <args…>`. Returns trimmed stdout, or '' when empty. */
@@ -42,6 +49,7 @@ export const talosctl = (args: readonly string[], options: TalosRunOptions) =>
42
49
  options.talosconfigPath,
43
50
  ...(options.nodes === undefined ? [] : ['--nodes', options.nodes.join(',')]),
44
51
  ...(options.endpoints === undefined ? [] : ['--endpoints', options.endpoints.join(',')]),
52
+ ...(options.insecure === true ? ['--insecure'] : []),
45
53
  ];
46
54
  const result = yield* ChildProcess.make('talosctl', argv, {
47
55
  detached: false,
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import assert from 'node:assert/strict';
14
14
  import { describe, it } from 'node:test';
15
- import { configDigest, kubeconfigMetadata, resolveConfigPath, sha256 } from './values.ts';
15
+ import { configDigest, extractMachineConfigSpec, kubeconfigMetadata, sha256 } from './values.ts';
16
16
 
17
17
  /** Plain words, base64-encoded here rather than in the source. See the ⛔ above. */
18
18
  const b64 = (words: string) => Buffer.from(words).toString('base64');
@@ -69,12 +69,40 @@ describe('kubeconfigMetadata', () => {
69
69
  });
70
70
  });
71
71
 
72
- describe('resolveConfigPath', () => {
73
- it('passes absolute paths through', () => {
74
- assert.equal(resolveConfigPath('/stack', '/etc/talos/cp.yaml'), '/etc/talos/cp.yaml');
72
+ describe('extractMachineConfigSpec', () => {
73
+ /** Shape REASONED from talosctl's `get machineconfig -o yaml` — see values.ts's own header. */
74
+ const wrapper = (version: string) =>
75
+ `node: 192.0.2.10\nmetadata:\n version: "${version}"\nspec: "machine:\\n type: worker\\n"\n`;
76
+
77
+ it('hashes only the spec text, not the wrapper metadata', () => {
78
+ const specA = extractMachineConfigSpec(wrapper('7'));
79
+ const specB = extractMachineConfigSpec(wrapper('9')); // metadata differs, spec does not
80
+ assert.ok(specA !== undefined && specB !== undefined);
81
+ assert.equal(specA, specB);
82
+ assert.equal(configDigest(specA), configDigest(specB));
83
+ });
84
+
85
+ it('unwraps a single-element array response the same way', () => {
86
+ const asArrayYaml =
87
+ '- node: 192.0.2.10\n metadata:\n version: "7"\n spec: "machine:\\n type: worker\\n"\n';
88
+ const asDoc = extractMachineConfigSpec(wrapper('7'));
89
+ const asArray = extractMachineConfigSpec(asArrayYaml);
90
+ assert.ok(asDoc !== undefined && asArray !== undefined);
91
+ assert.equal(asDoc, asArray);
92
+ });
93
+
94
+ it('returns undefined for unparsable YAML or a missing/non-string spec', () => {
95
+ assert.equal(extractMachineConfigSpec(':::not yaml:::'), undefined);
96
+ assert.equal(extractMachineConfigSpec('node: 192.0.2.10\n'), undefined);
97
+ assert.equal(extractMachineConfigSpec('spec:\n nested: true\n'), undefined);
75
98
  });
76
99
 
77
- it('joins relative paths to the stack directory', () => {
78
- assert.equal(resolveConfigPath('/stack', 'configs/cp.yaml'), '/stack/configs/cp.yaml');
100
+ it('refuses more than one document rather than guessing which one (fix-first #2)', () => {
101
+ // Shape of an unfiltered `get machineconfig`: `persistent` then `v1alpha1`, sorted by id — a
102
+ // bare `doc[0]` here would silently pick `persistent`, the shipped bug this guard replaces.
103
+ const twoDocs =
104
+ '- node: 192.0.2.10\n metadata:\n id: persistent\n spec: "not the applied config"\n' +
105
+ '- node: 192.0.2.10\n metadata:\n id: v1alpha1\n spec: "machine:\\n type: worker\\n"\n';
106
+ assert.equal(extractMachineConfigSpec(twoDocs), undefined);
79
107
  });
80
108
  });
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Coercions and digests for Talos resources — every function here exists because a trap does.
3
3
  *
4
- * ⚠️ NOTHING IN THIS FILE TOUCHES A LIVE CLUSTER. Digests are computed from repo files or from
5
- * kubeconfig YAML already on disk at reconcile time; tests use fixtures only.
4
+ * ⚠️ NOTHING IN THIS FILE TOUCHES A LIVE CLUSTER OR OpenBao. Digests are computed from KV content
5
+ * or talosctl output already fetched by the caller, and kubeconfig YAML already on disk at
6
+ * reconcile time; tests use fixtures only.
6
7
  */
7
8
  import { createHash } from 'node:crypto';
8
9
 
@@ -13,9 +14,46 @@ export const canonicalText = (text: string) => text.trimEnd();
13
14
  export const sha256 = (text: string) =>
14
15
  createHash('sha256').update(canonicalText(text)).digest('hex');
15
16
 
16
- /** Digest of a machine config file read from the repo. ⛔ Never persist the file body itself. */
17
+ /** Digest of a machine config's canonical text. ⛔ Never persist the text itself. */
17
18
  export const configDigest = (text: string) => sha256(text);
18
19
 
20
+ /**
21
+ * Extract the raw config text under `spec` from `talosctl get machineconfig -o yaml`'s wrapper.
22
+ *
23
+ * ⛔ THE WHOLE WRAPPER CAN NEVER MATCH THE PINNED DIGEST, AND HASHING IT WAS THE SHIPPED BUG.
24
+ * REASONED from talosctl source (`cmd/talosctl/.../output/yaml.go:63`,
25
+ * `resources/config/machine_config.go:40-50`): the resource envelope nests a `node:` line and
26
+ * resource metadata (version, timestamps) around the config, which is itself a raw YAML STRING
27
+ * under `spec`. Metadata changes on every observation, so `configDigest(wrapper)` never equals
28
+ * `configDigest(seededConfigText)` even when nothing changed — every reconcile would die, every
29
+ * diff would plan `update`, verify would never go quiet (all three MEASURED against the shipped
30
+ * code's shape, docs/plans/2026-09-26-talos-stack-first-boot.md).
31
+ * ⚠️ Returns `undefined` for any shape without a string `spec`, so a caller cannot mistake
32
+ * "unparsable wrapper" for "empty config" — those must fail differently (the caller's job).
33
+ * ⛔ MORE THAN ONE DOCUMENT IS REFUSED, NEVER `doc[0]` (fix-first #2, PR 307 red team). An unfiltered
34
+ * `get machineconfig` lists BOTH the `persistent` and `v1alpha1` resources, sorted by id
35
+ * (REASONED, cosi-project/runtime `inmem/collection.go:122-124`), so a bare `doc[0]` is always
36
+ * `persistent` — never the config this package applies. The caller (machine-config-read.ts) now
37
+ * always requests the single named resource (`get machineconfig v1alpha1`); a second document
38
+ * showing up here means that request shape changed without this guard changing with it, and
39
+ * silently picking one would repeat the exact shipped bug.
40
+ */
41
+ export const extractMachineConfigSpec = (wrapperYaml: string): string | undefined => {
42
+ let doc: unknown;
43
+ try {
44
+ doc = Bun.YAML.parse(wrapperYaml);
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ if (Array.isArray(doc)) {
49
+ if (doc.length !== 1) return undefined;
50
+ doc = doc[0];
51
+ }
52
+ if (doc === null || typeof doc !== 'object') return undefined;
53
+ const spec = (doc as { spec?: unknown }).spec;
54
+ return typeof spec === 'string' ? spec : undefined;
55
+ };
56
+
19
57
  type KubeconfigDoc = {
20
58
  clusters?: {
21
59
  cluster?: { server?: string; 'certificate-authority-data'?: string };
@@ -64,7 +102,3 @@ export const kubeconfigMetadata = (
64
102
  endpoint,
65
103
  };
66
104
  };
67
-
68
- /** Resolve a config file path relative to the declaring stack file. */
69
- export const resolveConfigPath = (stackDir: string, configFile: string) =>
70
- configFile.startsWith('/') ? configFile : `${stackDir}/${configFile}`;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A fake OTLP/HTTP collector for the tests beside it — `Bun.serve` on an ephemeral 127.0.0.1 port,
3
+ * answering every POST with 200 and recording each request's path, headers and raw body bytes.
4
+ *
5
+ * ⛔ TEST-ONLY, and no live call ever leaves this process: nothing here reaches a real Victoria
6
+ * endpoint, satisfying the workflow's "no live calls to Victoria from tests" rule. A consumer's
7
+ * own integration test points `telemetryLayer` at ITS running Victoria; this file exists so this
8
+ * package's tests never need one.
9
+ * ★ 200 UNCONDITIONALLY. `OtlpExporter.make` treats any non-2xx as a transient failure and retries
10
+ * three times with backoff (OtlpExporter.ts `policy`/`retryTransient`) — a fake that ever answered
11
+ * with an error would make a test wait through that retry schedule for no reason.
12
+ * ⛔ RAW BYTES, NOT PARSED JSON (redteam PR 302 CRITICAL item 1, 2026-09-26): `telemetryLayer` now
13
+ * sends `OtlpSerialization.layerProtobuf`, not JSON, so there is no body to `JSON.parse`. `sawText`
14
+ * below is what every test needing to inspect content uses instead — it works unchanged for either
15
+ * encoding, since protobuf's string fields are raw UTF-8 with no escaping: a leaked literal (a
16
+ * secret, a span name, `<redacted>`) always appears as a contiguous byte run in the raw body,
17
+ * exactly like it would in JSON, with no OTLP protobuf decoder needed to find it.
18
+ */
19
+ export interface CollectedRequest {
20
+ readonly path: string;
21
+ readonly headers: Headers;
22
+ readonly raw: Uint8Array;
23
+ }
24
+
25
+ export interface FakeCollector {
26
+ readonly url: string;
27
+ readonly requests: CollectedRequest[];
28
+ /** `requests` filtered to POSTs at exactly this path (e.g. `/v1/traces`). */
29
+ at(path: string): CollectedRequest[];
30
+ /** True when `text`'s bytes appear anywhere in ANY collected request's raw body. ASCII text (a
31
+ * secret, a hostname, `<redacted>`) decodes to itself regardless of what surrounds it in an
32
+ * otherwise-binary protobuf buffer — see this file's own header for why that's sound. */
33
+ sawText(text: string): boolean;
34
+ stop(): void;
35
+ }
36
+
37
+ const rawTextDecoder = new TextDecoder('utf-8', { fatal: false });
38
+
39
+ export const fakeCollector = (): FakeCollector => {
40
+ const requests: CollectedRequest[] = [];
41
+ const server = Bun.serve({
42
+ fetch: async (request) => {
43
+ const url = new URL(request.url);
44
+ const raw = new Uint8Array(await request.arrayBuffer());
45
+ requests.push({ headers: request.headers, path: url.pathname, raw });
46
+ return new Response('{}', { headers: { 'content-type': 'application/json' }, status: 200 });
47
+ },
48
+ hostname: '127.0.0.1',
49
+ port: 0,
50
+ });
51
+ return {
52
+ at: (path) => requests.filter((request) => request.path === path),
53
+ requests,
54
+ sawText: (text) =>
55
+ requests.some((request) => rawTextDecoder.decode(request.raw).includes(text)),
56
+ stop: () => void server.stop(true),
57
+ url: `http://127.0.0.1:${String(server.port ?? 0)}`,
58
+ };
59
+ };