@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,256 @@
1
+ /**
2
+ * `Talos.Kubeconfig` landing in OpenBao, written once at bring-up (K-talos-first-boot). Fully
3
+ * offline: fake-process.ts fakes `bao`/`talosctl`; the fake `talosctl kubeconfig` handler writes a
4
+ * fixture file to the temp path it was given, standing in for what the real binary would do.
5
+ *
6
+ * ⚠️ FIXTURES ONLY — see values.test.ts's own header for why the base64 fields are built from
7
+ * plain words at run time rather than written out literally.
8
+ */
9
+ import assert from 'node:assert/strict';
10
+ import { existsSync, writeFileSync } from 'node:fs';
11
+ import { describe, it } from 'node:test';
12
+ import * as Effect from 'effect/Effect';
13
+ import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
14
+ import { type FakeCall, fakeSpawner } from './fake-process.ts';
15
+ import {
16
+ type KubeconfigAttributes,
17
+ type KubeconfigProps,
18
+ diffKubeconfig as diff,
19
+ readKubeconfig as read,
20
+ reconcileKubeconfig as reconcile,
21
+ } from './kubeconfig.ts';
22
+
23
+ const b64 = (words: string) => Buffer.from(words).toString('base64');
24
+
25
+ const fixtureKubeconfig = (marker: string) => `
26
+ apiVersion: v1
27
+ kind: Config
28
+ clusters:
29
+ - name: hf-c1
30
+ cluster:
31
+ server: https://192.0.2.50:6443
32
+ certificate-authority-data: ${b64(`not a ca ${marker}`)}
33
+ contexts:
34
+ - name: admin@hf-c1
35
+ context: { cluster: hf-c1, user: admin@hf-c1 }
36
+ users:
37
+ - name: admin@hf-c1
38
+ user:
39
+ client-certificate-data: ${b64(`not a certificate ${marker}`)}
40
+ client-key-data: ${b64(`not a key ${marker}`)}
41
+ current-context: admin@hf-c1
42
+ `;
43
+
44
+ const TARGET = { cluster: 'c1', mount: 'talos-c1' };
45
+ const props = (): KubeconfigProps => ({
46
+ context: 'admin@hf-c1',
47
+ node: '198.51.100.10',
48
+ target: TARGET,
49
+ });
50
+
51
+ const run = <A, E>(
52
+ effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
53
+ handler: (c: FakeCall) => { stdout?: string; stderr?: string; exitCode?: number },
54
+ calls: FakeCall[] = [],
55
+ ) =>
56
+ Effect.runPromise(
57
+ Effect.provideService(
58
+ effect,
59
+ ChildProcessSpawner.ChildProcessSpawner,
60
+ fakeSpawner(handler, calls),
61
+ ),
62
+ );
63
+
64
+ describe('reconcile — CREATE writes talosctl kubeconfig output into the vault, never a host path', () => {
65
+ it('mints the temp file, writes its content to vault via stdin, cleans up the temp file', async () => {
66
+ const calls: FakeCall[] = [];
67
+ let outPath = '';
68
+ const handler = (call: FakeCall) => {
69
+ if (call.command === 'bao' && call.args[1] === 'get') {
70
+ return { stdout: JSON.stringify({ data: { data: { talosconfig: 'fake-talosconfig' } } }) };
71
+ }
72
+ if (call.command === 'bao' && call.args[1] === 'put') return {};
73
+ if (call.command === 'talosctl' && call.args[0] === 'kubeconfig') {
74
+ outPath = call.args[1] ?? '';
75
+ writeFileSync(outPath, fixtureKubeconfig('create'));
76
+ return {};
77
+ }
78
+ throw new Error(`unexpected call ${JSON.stringify(call)}`);
79
+ };
80
+ const result = (await run(
81
+ reconcile(props(), undefined),
82
+ handler,
83
+ calls,
84
+ )) as KubeconfigAttributes;
85
+
86
+ assert.equal(result.endpoint, 'https://192.0.2.50:6443');
87
+ assert.equal(result.context, 'admin@hf-c1');
88
+ assert.equal(result.connection.auth.kind, 'kubeconfig');
89
+ // ⛔ I2 (LAND red team): NEVER undefined — an undefined `path` here would let the stock
90
+ // KubeConfigAdapter fall back to $KUBECONFIG/~/.kube/config. It must be a path that can never
91
+ // resolve, so a consumer that isn't yet wired to mintKubeconfig fails loudly instead of quietly
92
+ // reaching the operator's own cluster.
93
+ const authPath = (result.connection.auth as { path?: string }).path;
94
+ assert.ok(typeof authPath === 'string' && authPath.length > 0);
95
+
96
+ const put = calls.find((c) => c.command === 'bao' && c.args[1] === 'put');
97
+ assert.ok(put);
98
+ // ⛔ C2 (LAND red team): `=-`, never `=@-` — see credentials-write.ts's own header.
99
+ assert.deepEqual(put.args, ['kv', 'put', 'talos-c1/kubeconfig', 'kubeconfig=-']);
100
+ assert.equal(put.stdin, fixtureKubeconfig('create'));
101
+ assert.ok(!put.args.some((a) => a.includes('not a key')), 'never in argv');
102
+
103
+ assert.equal(existsSync(outPath), false, 'the captured temp file must be gone after reconcile');
104
+ });
105
+ });
106
+
107
+ describe('reconcile — write-once: a second reconcile never re-runs talosctl kubeconfig', () => {
108
+ it('confirms from the vault copy instead of minting a new admin cert', async () => {
109
+ const calls: FakeCall[] = [];
110
+ const priorOutput: KubeconfigAttributes = {
111
+ certificateAuthorityFingerprint: 'x',
112
+ clientCertificateFingerprint: 'x',
113
+ connection: { auth: { context: 'admin@hf-c1', kind: 'kubeconfig' } },
114
+ context: 'admin@hf-c1',
115
+ credentialGeneration: 'x',
116
+ endpoint: 'https://192.0.2.50:6443',
117
+ };
118
+ const handler = (call: FakeCall) => {
119
+ if (call.command === 'bao' && call.args[1] === 'get') {
120
+ return {
121
+ stdout: JSON.stringify({ data: { data: { config: fixtureKubeconfig('confirm') } } }),
122
+ };
123
+ }
124
+ throw new Error(`unexpected call ${JSON.stringify(call)}`);
125
+ };
126
+ const result = (await run(
127
+ reconcile(props(), priorOutput),
128
+ handler,
129
+ calls,
130
+ )) as KubeconfigAttributes;
131
+
132
+ assert.equal(result.endpoint, 'https://192.0.2.50:6443');
133
+ assert.ok(
134
+ calls.every(
135
+ (c) => !(c.command === 'talosctl') && !(c.command === 'bao' && c.args[1] === 'put'),
136
+ ),
137
+ 'a confirm-only reconcile never spawns talosctl or writes the vault again',
138
+ );
139
+ });
140
+ });
141
+
142
+ describe('read — C1 fix: undefined means genuinely absent, never "some read failed"', () => {
143
+ it('returns undefined (never a defined empty object) when the vault key was never written', async () => {
144
+ // ⛔ MEASURED against OpenBao v2.6.2: this exit code + stderr text is exactly what `bao kv get`
145
+ // prints for a genuinely unwritten KV-v2 key — see kubeconfig.ts's `isVaultKeyAbsent` header.
146
+ const result = await run(read(props()), () => ({
147
+ exitCode: 2,
148
+ stderr: 'No value found at talos-c1/data/kubeconfig',
149
+ }));
150
+ assert.equal(result, undefined);
151
+ });
152
+
153
+ it('propagates a genuine vault/transport failure instead of reading it as absence', async () => {
154
+ await assert.rejects(
155
+ run(read(props()), () => ({ exitCode: 2, stderr: 'permission denied' })),
156
+ (error: unknown) => error instanceof Error && error.message.includes('permission denied'),
157
+ );
158
+ });
159
+ });
160
+
161
+ describe('read → reconcile chained the way the Alchemy engine drives a cold start (C1 regression)', () => {
162
+ it('a cold-start read (undefined) followed by reconcile(undefined) actually creates', async () => {
163
+ // ⛔ Alchemy beta.79 Plan.ts:1303-1352 always calls `read` first when there is no state row; a
164
+ // DEFINED result there is adopted and forces `update`, so this is the exact sequence that made
165
+ // Talos.Kubeconfig un-creatable before the C1 fix — see kubeconfig.ts's own `readKubeconfig`
166
+ // header.
167
+ const calls: FakeCall[] = [];
168
+ const coldHandler = () => ({
169
+ exitCode: 2,
170
+ stderr: 'No value found at talos-c1/data/kubeconfig',
171
+ });
172
+ const readResult = await run(read(props()), coldHandler, calls);
173
+ assert.equal(
174
+ readResult,
175
+ undefined,
176
+ "the engine's adoption probe must see a genuine cold start",
177
+ );
178
+
179
+ const createHandler = (call: FakeCall) => {
180
+ if (call.command === 'bao' && call.args[1] === 'get') {
181
+ // ★ mintTalosconfig reads the `talosconfig` key first — only the `kubeconfig` key itself
182
+ // is genuinely absent at a cold start.
183
+ return call.args[3]?.endsWith('/kubeconfig')
184
+ ? coldHandler()
185
+ : { stdout: JSON.stringify({ data: { data: { talosconfig: 'fake-talosconfig' } } }) };
186
+ }
187
+ if (call.command === 'bao' && call.args[1] === 'put') return {};
188
+ if (call.command === 'talosctl' && call.args[0] === 'kubeconfig') {
189
+ writeFileSync(call.args[1] ?? '', fixtureKubeconfig('engine-order'));
190
+ return {};
191
+ }
192
+ throw new Error(`unexpected call ${JSON.stringify(call)}`);
193
+ };
194
+ const created = (await run(
195
+ reconcile(props(), readResult),
196
+ createHandler,
197
+ )) as KubeconfigAttributes;
198
+ assert.equal(created.endpoint, 'https://192.0.2.50:6443');
199
+ assert.notEqual(created.credentialGeneration, '');
200
+ });
201
+ });
202
+
203
+ describe('reconcile — belt-and-suspenders: a defined-but-empty output is treated as absent (C1)', () => {
204
+ it('a pre-fix-shaped empty output still runs CREATE instead of failing to confirm', async () => {
205
+ const staleEmptyOutput: KubeconfigAttributes = {
206
+ certificateAuthorityFingerprint: '',
207
+ clientCertificateFingerprint: '',
208
+ connection: { auth: { context: 'admin@hf-c1', kind: 'kubeconfig' } },
209
+ context: 'admin@hf-c1',
210
+ credentialGeneration: '',
211
+ endpoint: '',
212
+ };
213
+ const handler = (call: FakeCall) => {
214
+ if (call.command === 'bao' && call.args[1] === 'get') {
215
+ return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
216
+ }
217
+ if (call.command === 'bao' && call.args[1] === 'put') return {};
218
+ if (call.command === 'talosctl' && call.args[0] === 'kubeconfig') {
219
+ writeFileSync(call.args[1] ?? '', fixtureKubeconfig('stale-empty'));
220
+ return {};
221
+ }
222
+ throw new Error(`unexpected call ${JSON.stringify(call)}`);
223
+ };
224
+ const result = (await run(
225
+ reconcile(props(), staleEmptyOutput),
226
+ handler,
227
+ )) as KubeconfigAttributes;
228
+ assert.notEqual(result.credentialGeneration, '');
229
+ });
230
+ });
231
+
232
+ describe('diff', () => {
233
+ it('plans noop when the vault copy matches state', async () => {
234
+ const output: KubeconfigAttributes = (await run(
235
+ reconcile(props(), undefined),
236
+ (call: FakeCall) => {
237
+ if (call.command === 'bao' && call.args[1] === 'get') {
238
+ return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
239
+ }
240
+ if (call.command === 'bao' && call.args[1] === 'put') return {};
241
+ if (call.command === 'talosctl') {
242
+ writeFileSync(call.args[1] ?? '', fixtureKubeconfig('diff'));
243
+ return {};
244
+ }
245
+ throw new Error('unexpected');
246
+ },
247
+ )) as KubeconfigAttributes;
248
+
249
+ const result = await run(diff(props(), output), (call: FakeCall) =>
250
+ call.args[1] === 'get'
251
+ ? { stdout: JSON.stringify({ data: { data: { config: fixtureKubeconfig('diff') } } }) }
252
+ : {},
253
+ );
254
+ assert.equal(result?.action, 'noop');
255
+ });
256
+ });
@@ -5,37 +5,53 @@
5
5
  * `--merge=false` keeps it isolated; `-f/--force` overwrites an existing file.
6
6
  *
7
7
  * ⛔ THE KUBECONFIG FILE CONTAINS A CLIENT CERTIFICATE — IT MUST NOT BE AN ATTRIBUTE. Alchemy
8
- * persists attributes unencrypted. This resource:
9
- * - writes the file to `runtimePath` at reconcile time (re-minted each deploy),
10
- * - persists ONLY public metadata + fingerprints,
11
- * - exposes a `connection` field referencing the runtime path for `Kubernetes.ClusterAdapter`.
8
+ * persists attributes unencrypted. This resource persists ONLY public metadata + fingerprints,
9
+ * never the file's own bytes.
12
10
  *
13
- * ⚠️ `runtimePath` IS PERSISTED AS A STRING, NOT AS FILE CONTENTS. The path is a contract between
14
- * this resource and the host filesystem — same pattern as pointing Kubernetes.KubeConfig at
15
- * `/opt/homeflare/...` rendered outside Postgres.
11
+ * ⛔ LANDS IN OPENBAO, NOT A HOST `runtimePath` (K-talos-first-boot, 2026-09-26). The shipped shape
12
+ * wrote a cluster-admin kubeconfig to un-vaulted host disk and never removed it — the exact class
13
+ * of exposure `talos/credentials.ts`'s own header exists to prevent — and its own `read` depended
14
+ * on that file's continued presence, so verifying from any other host planned `update` forever
15
+ * (docs/plans/2026-09-26-talos-secrets-flow.md's "Measured today" section). Fixed:
16
+ * - CREATE (`output === undefined`) runs `talosctl kubeconfig` into a throwaway, unguessable
17
+ * temp path (this file's own `reservedTempPath`), reads it back, and writes its bytes into
18
+ * OpenBao at `target.mount`/`kubeconfigKey` via stdin (`credentials-write.ts`'s
19
+ * `writeKvValue` — ⛔ never argv) — never to any path a caller could depend on.
20
+ * - ⛔ WRITTEN ONCE, NOT RE-MINTED EVERY DEPLOY. `talosctl kubeconfig` always issues a FRESH
21
+ * admin client certificate; re-running it every reconcile would rotate credentials for no
22
+ * reason and violate O1's write-once-at-bring-up grant on this one key
23
+ * (docs/plans/2026-09-26-talos-secrets-flow.md, O1). Once `output` is defined, reconcile only
24
+ * reads the vault copy back to confirm it — the same "once" shape `talos-bootstrap.ts` uses
25
+ * for a boolean, here for a value.
26
+ * - The persisted `connection` carries no usable host path — a consumer materializes its own
27
+ * temp file via `credentials.ts`'s `mintKubeconfig`, the same pattern `mintTalosconfig`
28
+ * already established. Wiring `Kubernetes.ClusterAdapter` to call it is separate, later work
29
+ * (named, not built, in the secrets-flow doc); this only makes the vault-backed read+mint
30
+ * step exist for it to call. ⛔ Until that wiring lands, `connection.auth.path` is a sentinel
31
+ * that can never resolve to a real file (LAND red team I2, `kubeconfig-attrs.ts`'s own
32
+ * header) — an ABSENT path here would let the stock `Kubernetes.KubeConfigAdapter` silently
33
+ * fall back to `$KUBECONFIG`/`~/.kube/config` instead of failing loudly.
16
34
  */
35
+ import { chmodSync } from 'node:fs';
17
36
  import { Resource } from 'alchemy';
18
37
  import { isResolved } from 'alchemy/Diff';
19
38
  import type { Input } from 'alchemy/Input';
20
39
  import type { Connection } from 'alchemy/Kubernetes/Connection';
21
40
  import * as Provider from 'alchemy/Provider';
22
41
  import * as Effect from 'effect/Effect';
23
- import { mintTalosconfig } from './credentials.ts';
42
+ import { DEFAULT_KUBECONFIG_KEY, mintTalosconfig, readKvValue } from './credentials.ts';
43
+ import { reservedTempPath, writeKvValue } from './credentials-write.ts';
44
+ import { buildAttrs } from './kubeconfig-attrs.ts';
24
45
  import type { TalosRequirements, WithTarget } from './resource.ts';
25
46
  import { talosctl } from './talosctl.ts';
26
- import { kubeconfigMetadata, sha256 } from './values.ts';
27
47
 
28
48
  export interface KubeconfigProps extends WithTarget {
29
49
  /** Node that serves `talosctl kubeconfig`. */
30
50
  node: string;
31
51
  /** Context name inside the generated kubeconfig — not secret. */
32
52
  context: string;
33
- /**
34
- * Host path where talosctl writes the kubeconfig each reconcile.
35
- *
36
- * ⛔ Must be outside Alchemy state and rotated by redeploy, never copied into attributes.
37
- */
38
- runtimePath: string;
53
+ /** OpenBao KV path under `target.mount` for the written kubeconfig. Default `'kubeconfig'`. */
54
+ kubeconfigKey?: string;
39
55
  after?: readonly unknown[];
40
56
  }
41
57
 
@@ -50,7 +66,11 @@ export interface KubeconfigAttributes {
50
66
  * Serializable `Kubernetes.Connection` for downstream workloads.
51
67
  *
52
68
  * ★ auth.kind `kubeconfig` is the stock ClusterAdapter — provider-roadmap.md names this seam.
53
- * Only `path` and `context` appear here; the adapter reads credentials from disk at call time.
69
+ * `path` is a sentinel that can never resolve (LAND red team I2, `kubeconfig-attrs.ts`'s own
70
+ * `toConnection` header): the content lives in OpenBao, not at a fixed host path, and a real
71
+ * `Kubernetes.ClusterAdapter` wiring is later work, so this must fail rather than silently
72
+ * reach the operator's own kubeconfig. A consumer materializes its own scoped temp file via
73
+ * `credentials.ts`'s `mintKubeconfig` in the meantime.
54
74
  */
55
75
  connection: Connection;
56
76
  }
@@ -65,92 +85,146 @@ export interface TalosKubeconfig extends Resource<
65
85
 
66
86
  export const TalosKubeconfig = Resource<TalosKubeconfig>('Talos.Kubeconfig');
67
87
 
68
- const generation = (meta: {
69
- endpoint: string;
70
- caFingerprint: string;
71
- clientFingerprint: string;
72
- context: string;
73
- }) => sha256(`${meta.context}\n${meta.endpoint}\n${meta.caFingerprint}\n${meta.clientFingerprint}`);
88
+ /**
89
+ * ★ MEASURED AGAINST OPENBAO v2.6.2 (C1 fix, LAND red team, 2026-09-26 — isolated in-memory dev
90
+ * server, `env -i`, scratch HOME, no estate credentials; server removed after): `bao kv get` on a
91
+ * genuinely unwritten KV-v2 key exits 2 with stderr exactly `No value found at <mount>/data/<key>`
92
+ * — `readKvValue`'s Error message embeds that text verbatim. This is the ONLY signal this file
93
+ * trusts as "not written yet"; every other failure (a wrong exit code, a different stderr such as
94
+ * a permission denial or a network failure) propagates as a real error instead of being read as
95
+ * absence.
96
+ */
97
+ const isVaultKeyAbsent = (error: unknown): boolean =>
98
+ error instanceof Error && /no value found at/i.test(error.message);
74
99
 
75
- const toConnection = (props: KubeconfigProps): Connection => ({
76
- auth: { context: props.context, kind: 'kubeconfig', path: props.runtimePath },
77
- });
100
+ const readVaultMeta = (props: KubeconfigProps, key: string) =>
101
+ readKvValue(props.target.mount, key, ['kubeconfig', 'config']).pipe(
102
+ Effect.catchIf(isVaultKeyAbsent, () => Effect.succeed(undefined)),
103
+ );
78
104
 
79
- const readFileMeta = (props: KubeconfigProps) =>
105
+ /**
106
+ * ★ EXPORTED for kubeconfig.test.ts — see talos-bootstrap.ts's own note on the pattern.
107
+ *
108
+ * ⛔ C1 FIX (LAND red team, 2026-09-26) — `undefined` NOW MEANS ONLY "the key has never been
109
+ * written", NEVER "some read failed". The shipped `Effect.orElseSucceed` folded EVERY failure —
110
+ * including a vault outage or wrong mount — into a defined, empty-fingerprint object. Alchemy's
111
+ * engine always adopts a defined cold-start `read` result and forces `update` (alchemy beta.79
112
+ * `Plan.ts:1303-1352`), so `reconcileKubeconfig`'s write-once branch (`output !== undefined`)
113
+ * then tried to CONFIRM a key that had never actually been written, and failed every time —
114
+ * `Talos.Kubeconfig` could never be created. Vault content that EXISTS but does not parse as a
115
+ * kubeconfig is a different, fail-closed case (below), never treated as absence.
116
+ */
117
+ export const readKubeconfig = (props: KubeconfigProps) =>
80
118
  Effect.gen(function* () {
81
- const text = yield* Effect.tryPromise({
82
- try: () => Bun.file(props.runtimePath).text(),
83
- catch: (cause) => new Error(String(cause)),
84
- }).pipe(Effect.orElseSucceed(() => undefined as string | undefined));
85
- if (text === undefined) return undefined;
86
- const meta = kubeconfigMetadata(text, props.context);
87
- if (meta === undefined) return undefined;
88
- return {
89
- certificateAuthorityFingerprint: meta.caFingerprint,
90
- clientCertificateFingerprint: meta.clientFingerprint,
91
- connection: toConnection(props),
92
- context: props.context,
93
- credentialGeneration: generation({ ...meta, context: props.context }),
94
- endpoint: meta.endpoint,
95
- };
119
+ const key = props.kubeconfigKey ?? DEFAULT_KUBECONFIG_KEY;
120
+ const raw = yield* readVaultMeta(props, key);
121
+ if (raw === undefined) return undefined;
122
+ const meta = buildAttrs(raw, props);
123
+ if (meta === undefined) {
124
+ return yield* Effect.fail(
125
+ new Error(
126
+ `${props.target.mount}/${key}: vault content exists but does not parse as a kubeconfig ` +
127
+ `for context ${props.context} — not a cold start. Fix the KV entry or the pinned ` +
128
+ 'context rather than trusting an empty read.',
129
+ ),
130
+ );
131
+ }
132
+ return meta;
96
133
  });
97
134
 
98
- const read = (props: KubeconfigProps) =>
99
- readFileMeta(props).pipe(
100
- Effect.map((meta) =>
101
- meta === undefined
102
- ? {
103
- certificateAuthorityFingerprint: '',
104
- clientCertificateFingerprint: '',
105
- connection: toConnection(props),
106
- context: props.context,
107
- credentialGeneration: '',
108
- endpoint: '',
109
- }
110
- : meta,
111
- ),
112
- );
113
-
114
- const diff = (news: Input<KubeconfigProps>, output: KubeconfigAttributes | undefined) =>
135
+ export const diffKubeconfig = (
136
+ news: Input<KubeconfigProps>,
137
+ output: KubeconfigAttributes | undefined,
138
+ ) =>
115
139
  Effect.gen(function* () {
116
140
  if (output === undefined || !isResolved(news)) return undefined;
117
- const live = yield* read(news);
141
+ const live = yield* readKubeconfig(news);
118
142
  if (
119
- live.credentialGeneration !== '' &&
120
- output.credentialGeneration === live.credentialGeneration &&
121
- output.context === live.context &&
122
- output.endpoint === live.endpoint
143
+ live !== undefined &&
144
+ live.credentialGeneration === output.credentialGeneration &&
145
+ live.context === output.context &&
146
+ live.endpoint === output.endpoint
123
147
  ) {
124
148
  return { action: 'noop' } as const;
125
149
  }
126
150
  return { action: 'update' } as const;
127
151
  });
128
152
 
129
- const reconcile = (props: KubeconfigProps) =>
130
- Effect.gen(function* () {
131
- const credential = yield* mintTalosconfig(props.target);
132
- yield* talosctl(
133
- [
134
- 'kubeconfig',
135
- props.runtimePath,
136
- '--merge=false',
137
- '--force',
138
- '--force-context-name',
139
- props.context,
140
- ],
141
- { nodes: [props.node], talosconfigPath: credential.talosconfigPath },
142
- );
143
- const meta = yield* readFileMeta(props);
144
- if (meta === undefined) {
145
- return yield* Effect.die(
146
- new Error(
147
- `${props.runtimePath}: talosctl kubeconfig returned no error but the file is missing ` +
148
- 'or unparsable. Read back rather than trusting the exit code alone.',
149
- ),
153
+ /**
154
+ * ⛔ WRAPPED IN `Effect.scoped` — same C1-lifetime reasoning as every other Talos resource file:
155
+ * `mintTalosconfig` and `reservedTempPath` both contribute `Scope.Scope`, and this is the caller
156
+ * that owns their lifetime for the whole reconcile.
157
+ * ⛔ WRITE-ONCE — see this file's own header. A bring-up already ran; this branch never spawns
158
+ * `talosctl kubeconfig` or writes to the vault again.
159
+ * ⛔ GATED ON `credentialGeneration`, NOT JUST `output !== undefined` (C1 fix, LAND red team,
160
+ * belt-and-suspenders alongside the `read` fix above) — `output`'s own type still allows a
161
+ * defined-but-empty value (state persisted by a pre-fix build's `emptyAttrs()`, or any future
162
+ * caller that hands this function a stale row). Treat that exactly like `undefined` — mint and
163
+ * write once — rather than take the confirm-only branch and fail trying to read back a key that
164
+ * was never actually written.
165
+ */
166
+ export const reconcileKubeconfig = (
167
+ props: KubeconfigProps,
168
+ output: KubeconfigAttributes | undefined,
169
+ ) =>
170
+ Effect.scoped(
171
+ Effect.gen(function* () {
172
+ const key = props.kubeconfigKey ?? DEFAULT_KUBECONFIG_KEY;
173
+
174
+ if (output !== undefined && output.credentialGeneration !== '') {
175
+ const raw = yield* readKvValue(props.target.mount, key, ['kubeconfig', 'config']);
176
+ const meta = buildAttrs(raw, props);
177
+ if (meta === undefined) {
178
+ return yield* Effect.die(
179
+ new Error(
180
+ `${props.target.mount}/${key}: vault content no longer parses as a kubeconfig for ` +
181
+ `context ${props.context}. Read back rather than trusting the write once.`,
182
+ ),
183
+ );
184
+ }
185
+ return meta;
186
+ }
187
+
188
+ const credential = yield* mintTalosconfig(props.target);
189
+ const { path: outPath } = yield* reservedTempPath(
190
+ `${props.target.cluster}-${props.node}-kubeconfig-out`,
150
191
  );
151
- }
152
- return meta;
153
- });
192
+ yield* talosctl(
193
+ ['kubeconfig', outPath, '--merge=false', '--force', '--force-context-name', props.context],
194
+ { nodes: [props.node], talosconfigPath: credential.talosconfigPath },
195
+ );
196
+ const raw = yield* Effect.tryPromise({
197
+ catch: (cause) => new Error(`${outPath}: reading generated kubeconfig: ${String(cause)}`),
198
+ try: () => Bun.file(outPath).text(),
199
+ });
200
+ if (raw.trim() === '') {
201
+ return yield* Effect.die(
202
+ new Error(
203
+ `${props.node}: talosctl kubeconfig returned no error but wrote an empty file. Read ` +
204
+ 'back rather than trusting the exit code alone.',
205
+ ),
206
+ );
207
+ }
208
+ // ⚠️ REASONED, not measured: talosctl's own writer likely already uses 0600 like most Go
209
+ // credential writers, but this chmods defensively regardless — same posture as
210
+ // credentials.ts's mintKvTempFile.
211
+ yield* Effect.try({
212
+ catch: (cause) => new Error(`${outPath}: chmod 0600: ${String(cause)}`),
213
+ try: () => chmodSync(outPath, 0o600),
214
+ });
215
+ const meta = buildAttrs(raw, props);
216
+ if (meta === undefined) {
217
+ return yield* Effect.die(
218
+ new Error(
219
+ `${props.node}: talosctl kubeconfig wrote a file that does not parse for context ` +
220
+ `${props.context}.`,
221
+ ),
222
+ );
223
+ }
224
+ yield* writeKvValue(props.target.mount, key, 'kubeconfig', raw);
225
+ return meta;
226
+ }),
227
+ );
154
228
 
155
229
  const handlers = {
156
230
  delete: () => Effect.void,
@@ -160,10 +234,16 @@ const handlers = {
160
234
  }: {
161
235
  news: Input<KubeconfigProps>;
162
236
  output: KubeconfigAttributes | undefined;
163
- }) => diff(news, output),
237
+ }) => diffKubeconfig(news, output),
164
238
  list: () => Effect.succeed([]),
165
- read: ({ olds }: { olds: KubeconfigProps }) => read(olds),
166
- reconcile: ({ news }: { news: KubeconfigProps }) => reconcile(news),
239
+ read: ({ olds }: { olds: KubeconfigProps }) => readKubeconfig(olds),
240
+ reconcile: ({
241
+ news,
242
+ output,
243
+ }: {
244
+ news: KubeconfigProps;
245
+ output: KubeconfigAttributes | undefined;
246
+ }) => reconcileKubeconfig(news, output),
167
247
  };
168
248
 
169
249
  export const TalosKubeconfigProvider = () =>
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Shared fixtures for talos-machine-config.test.ts and machine-config-poll.test.ts — split out
3
+ * so neither test file needs to duplicate the fake `bao`/`talosctl` dispatcher (2026-09-26, K-A3).
4
+ *
5
+ * ⚠️ FIXTURES ONLY. `CONFIG_TEXT` is a plain marker string, never anything resembling a real
6
+ * Talos machine config, CA key or bootstrap token. No provider imports this file.
7
+ */
8
+ import * as Effect from 'effect/Effect';
9
+ import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
10
+ import { type FakeCall, fakeSpawner } from './fake-process.ts';
11
+ import type { MachineConfigProps } from './talos-machine-config.ts';
12
+ import { configDigest } from './values.ts';
13
+
14
+ export const CONFIG_TEXT = 'machine:\n type: worker\n';
15
+ export const PIN = configDigest(CONFIG_TEXT);
16
+ export const TARGET = { cluster: 'c1', mount: 'talos-c1' };
17
+ export const FAST_POLL = {
18
+ immediate: { intervalMs: 0, maxAttempts: 3 },
19
+ rebootish: { intervalMs: 0, maxAttempts: 4 },
20
+ };
21
+
22
+ export const props = (overrides: Partial<MachineConfigProps> = {}): MachineConfigProps => ({
23
+ configDigest: PIN,
24
+ configKey: 'nodes/10001',
25
+ node: '198.51.100.10',
26
+ target: TARGET,
27
+ ...overrides,
28
+ });
29
+
30
+ /** Shape REASONED from talosctl's `get machineconfig -o yaml` — see values.ts's own header. */
31
+ export const wrapperFor = (specText: string, version = '1') =>
32
+ `node: 198.51.100.10\nmetadata:\n version: "${version}"\nspec: "${specText.replace(/\n/g, '\\n')}"\n`;
33
+
34
+ /** Routes fake `bao kv get` (config + talosconfig keys) and `talosctl` calls in one place. */
35
+ export const dispatcher = (opts: {
36
+ configText?: string;
37
+ liveWrapper?: () => { stdout?: string; exitCode?: number };
38
+ applyExitCode?: number;
39
+ /**
40
+ * Controls the fix-first #1 maintenance-mode probe (`talosctl version --insecure`), reached only
41
+ * when the authenticated `get machineconfig` above fails. Defaults to `false` so a bare failing
42
+ * `liveWrapper` (most tests) still exercises the "both reads fail → propagate" path rather than
43
+ * silently succeeding at the probe.
44
+ */
45
+ maintenanceReachable?: boolean;
46
+ }) => {
47
+ const configText = opts.configText ?? CONFIG_TEXT;
48
+ return (call: FakeCall) => {
49
+ if (call.command === 'bao') {
50
+ const key = call.args[3] ?? '';
51
+ if (key.endsWith('/talosconfig')) {
52
+ return {
53
+ stdout: JSON.stringify({ data: { data: { talosconfig: 'fake-talosconfig-body' } } }),
54
+ };
55
+ }
56
+ return { stdout: JSON.stringify({ data: { data: { config: configText } } }) };
57
+ }
58
+ if (call.command === 'talosctl') {
59
+ if (call.args[0] === 'apply-config') return { exitCode: opts.applyExitCode ?? 0 };
60
+ if (call.args[0] === 'version')
61
+ return (opts.maintenanceReachable ?? false)
62
+ ? { stdout: 'Client: v1.13.10\nServer: v1.13.10\n' }
63
+ : { exitCode: 1, stderr: 'dial tcp: connection refused' };
64
+ if (call.args[0] === 'get')
65
+ return opts.liveWrapper?.() ?? { stdout: wrapperFor(CONFIG_TEXT) };
66
+ }
67
+ throw new Error(`dispatcher: unexpected call ${JSON.stringify(call)}`);
68
+ };
69
+ };
70
+
71
+ export const run = <A, E>(
72
+ effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
73
+ handler: (c: FakeCall) => { stdout?: string; stderr?: string; exitCode?: number },
74
+ calls: FakeCall[] = [],
75
+ ) =>
76
+ Effect.runPromise(
77
+ Effect.provideService(
78
+ effect,
79
+ ChildProcessSpawner.ChildProcessSpawner,
80
+ fakeSpawner(handler, calls),
81
+ ),
82
+ );
83
+
84
+ /** `Data.TaggedError` stamps `_tag`, not `.message` — see talos-errors.ts. */
85
+ export const hasTag = (tag: string) => (error: unknown) =>
86
+ (error as { _tag?: string } | null)?._tag === tag;