@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
@@ -6,6 +6,22 @@
6
6
  * the bootstrap node. This command must run exactly once per cluster.
7
7
  *
8
8
  * ⛔ NOT A FACTORY RESOURCE — bootstrap cannot be deleted or updated in place; `delete` is a no-op.
9
+ *
10
+ * ⛔ ONCE MEANS ONCE (K-talos-first-boot, 2026-09-26) — THE SHIPPED SHAPE COULD BOOTSTRAP A SECOND
11
+ * etcd CLUSTER. The original `isBootstrapped` turned every read failure into `false`
12
+ * (`Effect.orElseSucceed`), `diff` then planned `update`, and `reconcile` re-ran `talosctl
13
+ * bootstrap` — Talos's only server-side guard is a non-empty etcd data directory (REASONED,
14
+ * `v1alpha1_server.go:441`), so a reinstalled or reset bootstrap node re-bootstrapped a fresh,
15
+ * isolated single-member cluster: split brain. Fixed per
16
+ * docs/plans/2026-09-26-talos-stack-first-boot.md's "Bootstrap — once means once" section:
17
+ * - `read` now answers presence/absence correctly instead of always returning a defined
18
+ * object — `undefined` only from a SUCCESSFUL read that finds no members (cold-start
19
+ * absence); a failing read propagates its error instead of being reinterpreted as absence.
20
+ * - `diff` trusts `output.bootstrapped` once it is `true` and reports `noop` without touching
21
+ * the live cluster at all — confirmation is `reconcile`'s job now, not plan's.
22
+ * - `reconcile` checks `output?.bootstrapped` FIRST: once true, it NEVER calls `talosctl
23
+ * bootstrap` again, no matter what a live read says. A live read that fails OR comes back
24
+ * empty both raise the same `TalosReBootstrapRefused` — re-bootstrap is a human decision.
9
25
  */
10
26
  import { Resource } from 'alchemy';
11
27
  import { isResolved } from 'alchemy/Diff';
@@ -14,11 +30,22 @@ import * as Provider from 'alchemy/Provider';
14
30
  import * as Effect from 'effect/Effect';
15
31
  import { mintTalosconfig } from './credentials.ts';
16
32
  import type { TalosRequirements, WithTarget } from './resource.ts';
17
- import { talosctl, talosctlOrAlready } from './talosctl.ts';
33
+ import { assertNoLivePeers, isBootstrapped } from './talos-bootstrap-etcd.ts';
34
+ import { TalosReBootstrapRefused } from './talos-errors.ts';
35
+ import { talosctlOrAlready } from './talosctl.ts';
18
36
 
19
37
  export interface BootstrapProps extends WithTarget {
20
38
  /** The ONE control-plane node that receives `talosctl bootstrap`. */
21
39
  node: string;
40
+ /**
41
+ * The OTHER declared control-plane nodes — never `node` itself. Optional, but a multi-control-
42
+ * plane cluster (decision 66: 3, one per TB4 node) SHOULD set it: `reconcileBootstrap`'s CREATE
43
+ * path (I4 fix, LAND red team, 2026-09-26) requires every one of these to show a successful,
44
+ * EMPTY etcd-members read before it will bootstrap `node` — the guard against re-bootstrapping a
45
+ * split-brain second cluster when this row's own state was lost. See talos-bootstrap-etcd.ts's
46
+ * `assertNoLivePeers`.
47
+ */
48
+ peers?: readonly string[];
22
49
  /** Machine configs for this node (and peers) must be applied first. */
23
50
  after?: readonly unknown[];
24
51
  }
@@ -42,54 +69,127 @@ export const TalosBootstrap = Resource<TalosBootstrap>('Talos.Bootstrap', {
42
69
  });
43
70
 
44
71
  /**
45
- * REASONED: `talosctl get etcdmembers -o json` should list members after bootstrap.
46
- * ⚠️ NOT MEASURED — first live deploy confirms the resource type name.
72
+ * ⛔ WRAPPED IN `Effect.scoped` (K-A3, 2026-09-26) — `mintTalosconfig` contributes `Scope.Scope`
73
+ * to its own return type rather than closing its scope internally (the C1 fix, credentials.ts's
74
+ * own header); this is the caller that owns the temp talosconfig's lifetime for the read.
75
+ *
76
+ * ⛔ ANSWERS PRESENCE/ABSENCE, NOT "attrs-or-error" (K-talos-first-boot, fixes the shipped shape).
77
+ * `undefined` only when a read SUCCEEDS and finds no members — the honest "not created yet"
78
+ * Alchemy's engine expects from a cold-start adoption probe. A failing read propagates instead
79
+ * of being reinterpreted as absence — this is the fix `isBootstrapped` no longer hides.
80
+ *
81
+ * ★ EXPORTED (like proxmox/ceph-daemon.ts's `reconcileDaemon`, machine-config-read.ts's
82
+ * `readMachineConfig`) so talos-bootstrap.test.ts calls it directly with a fake spawner instead
83
+ * of driving it through the full Alchemy engine.
47
84
  */
48
- const isBootstrapped = (props: BootstrapProps, talosconfigPath: string) =>
49
- talosctl(['get', 'etcdmembers', '-o', 'json'], {
50
- nodes: [props.node],
51
- talosconfigPath,
52
- }).pipe(
53
- Effect.map((text) => text.includes('"id"') || text.includes('"member"')),
54
- Effect.orElseSucceed(() => false),
85
+ export const readBootstrap = (props: BootstrapProps) =>
86
+ Effect.scoped(
87
+ Effect.gen(function* () {
88
+ const credential = yield* mintTalosconfig(props.target);
89
+ const bootstrapped = yield* isBootstrapped(props, credential.talosconfigPath);
90
+ return bootstrapped ? ({ bootstrapped: true, node: props.node } as const) : undefined;
91
+ }),
55
92
  );
56
93
 
57
- const read = (props: BootstrapProps) =>
58
- Effect.gen(function* () {
59
- const credential = yield* mintTalosconfig(props.target);
60
- const bootstrapped = yield* isBootstrapped(props, credential.talosconfigPath);
61
- return { bootstrapped, node: props.node };
62
- });
63
-
64
- const diff = (news: Input<BootstrapProps>, output: BootstrapAttributes | undefined) =>
94
+ /**
95
+ * ⛔ TRUSTS STATE, NEVER TOUCHES THE LIVE CLUSTER (K-talos-first-boot) — once `output.bootstrapped`
96
+ * is `true` this always reports `noop`, unconditionally, with zero live calls.
97
+ *
98
+ * ⛔ I3 CORRECTION (LAND red team, 2026-09-26) — THE OLD COMMENT HERE CLAIMED "confirming the
99
+ * invariant against etcd is reconcile's job (every deploy calls it)". THAT IS FALSE, VERIFIED
100
+ * against the pinned alchemy beta.79 source: Apply never reconciles a node whose diff came back
101
+ * `noop` (`Apply.ts`'s `noop` branch just refreshes row metadata and signals ready — it does not
102
+ * call `provider.reconcile` at all). This file's own `noop` above IS what skips it. So
103
+ * `reconcileBootstrap`'s re-confirm branch (the one that raises `TalosReBootstrapRefused` on a
104
+ * failed or empty read) never runs on an ordinary `plan`/`deploy` once `bootstrapped: true` is
105
+ * recorded — only `--force` reaches it, by forcing a reconcile regardless of the plan action.
106
+ * Safety still holds — `talosctl bootstrap` genuinely never re-runs while state exists — but a
107
+ * reset control-plane node (etcd wiped, VM replaced) stays silently invisible to `plan` and
108
+ * `deploy` alike until an operator runs `--force` or a verify pass.
109
+ * ⚠️ NOT FIXED BY HAVING `diff` READ HERE TOO (the finding's other offered fix) — that would touch
110
+ * the live cluster (and mint a talosconfig) on EVERY plan for an already-bootstrapped node, for a
111
+ * scenario (a reset node) that a `configDigest`-style pinned check can't distinguish from a merely
112
+ * slow/unreachable read; a single transient failure would then abort every future plan for a
113
+ * healthy node, for no benefit — the exact cost the original (correct) half of this comment
114
+ * already named. `MachineConfig`/`ClusterHealth`'s diffs DO mint and read live on every plan
115
+ * (D2 puts that on the admin/operator lane, not agent), so this isn't a lane-access limit; it is
116
+ * this resource's own choice to trust a `true` boolean forever rather than re-derive it from a
117
+ * live signal that cannot yet tell "reset" apart from "briefly unreachable". Recorded as an open
118
+ * gap for whoever builds the operator verify pass, not silently fixed here.
119
+ */
120
+ export const diffBootstrap = (
121
+ news: Input<BootstrapProps>,
122
+ output: BootstrapAttributes | undefined,
123
+ ) =>
65
124
  Effect.gen(function* () {
66
125
  if (output === undefined || !isResolved(news)) return undefined;
67
- const live = yield* read(news);
68
- if (live.bootstrapped) return { action: 'noop' } as const;
69
- return { action: 'update' } as const;
126
+ if (output.bootstrapped) return { action: 'noop' } as const;
127
+ const live = yield* readBootstrap(news);
128
+ return live?.bootstrapped === true
129
+ ? ({ action: 'noop' } as const)
130
+ : ({ action: 'update' } as const);
70
131
  });
71
132
 
72
- const reconcile = (props: BootstrapProps) =>
73
- Effect.gen(function* () {
74
- const credential = yield* mintTalosconfig(props.target);
75
- const before = yield* isBootstrapped(props, credential.talosconfigPath);
76
- if (!before) {
77
- yield* talosctlOrAlready(['bootstrap'], {
78
- nodes: [props.node],
79
- talosconfigPath: credential.talosconfigPath,
80
- });
81
- }
82
- const after = yield* isBootstrapped(props, credential.talosconfigPath);
83
- if (!after) {
84
- return yield* Effect.die(
85
- new Error(
86
- `${props.node}: bootstrap returned no error but etcd members are still absent. ` +
87
- 'Read back rather than trusting the exit code alone.',
88
- ),
89
- );
90
- }
91
- return { bootstrapped: true, node: props.node };
92
- });
133
+ /**
134
+ * ⛔ ONCE MEANS ONCE — see this file's own header. `output?.bootstrapped === true` is checked
135
+ * BEFORE anything else, and that branch never builds a `talosctl bootstrap` argv at all — the
136
+ * confirmation read below can fail, or succeed and say "absent", and EITHER way this raises
137
+ * `TalosReBootstrapRefused` rather than treating "absent" as licence to bootstrap again.
138
+ */
139
+ export const reconcileBootstrap = (
140
+ props: BootstrapProps,
141
+ output: BootstrapAttributes | undefined,
142
+ ) =>
143
+ Effect.scoped(
144
+ Effect.gen(function* () {
145
+ const credential = yield* mintTalosconfig(props.target);
146
+
147
+ if (output?.bootstrapped === true) {
148
+ const stillBootstrapped = yield* isBootstrapped(props, credential.talosconfigPath).pipe(
149
+ Effect.catchIf(
150
+ (): boolean => true,
151
+ (cause) =>
152
+ Effect.fail(
153
+ new TalosReBootstrapRefused({
154
+ detail: `live read failed: ${String(cause)}`,
155
+ node: props.node,
156
+ }),
157
+ ),
158
+ ),
159
+ );
160
+ if (!stillBootstrapped) {
161
+ return yield* Effect.fail(
162
+ new TalosReBootstrapRefused({
163
+ detail: 'etcd members are absent on a live read against already-bootstrapped state',
164
+ node: props.node,
165
+ }),
166
+ );
167
+ }
168
+ return { bootstrapped: true, node: props.node };
169
+ }
170
+
171
+ const before = yield* isBootstrapped(props, credential.talosconfigPath);
172
+ if (!before) {
173
+ // ⛔ I4 FIX (LAND red team) — see BootstrapProps.peers's own doc and
174
+ // talos-bootstrap-etcd.ts's assertNoLivePeers header.
175
+ yield* assertNoLivePeers(props, credential.talosconfigPath);
176
+ yield* talosctlOrAlready(['bootstrap'], {
177
+ nodes: [props.node],
178
+ talosconfigPath: credential.talosconfigPath,
179
+ });
180
+ }
181
+ const after = yield* isBootstrapped(props, credential.talosconfigPath);
182
+ if (!after) {
183
+ return yield* Effect.die(
184
+ new Error(
185
+ `${props.node}: bootstrap returned no error but etcd members are still absent. ` +
186
+ 'Read back rather than trusting the exit code alone.',
187
+ ),
188
+ );
189
+ }
190
+ return { bootstrapped: true, node: props.node };
191
+ }),
192
+ );
93
193
 
94
194
  const handlers = {
95
195
  delete: () => Effect.void,
@@ -99,10 +199,16 @@ const handlers = {
99
199
  }: {
100
200
  news: Input<BootstrapProps>;
101
201
  output: BootstrapAttributes | undefined;
102
- }) => diff(news, output),
202
+ }) => diffBootstrap(news, output),
103
203
  list: () => Effect.succeed([]),
104
- read: ({ olds }: { olds: BootstrapProps }) => read(olds),
105
- reconcile: ({ news }: { news: BootstrapProps }) => reconcile(news),
204
+ read: ({ olds }: { olds: BootstrapProps }) => readBootstrap(olds),
205
+ reconcile: ({
206
+ news,
207
+ output,
208
+ }: {
209
+ news: BootstrapProps;
210
+ output: BootstrapAttributes | undefined;
211
+ }) => reconcileBootstrap(news, output),
106
212
  };
107
213
 
108
214
  export const TalosBootstrapProvider = () =>
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `Talos.ClusterHealth` — no swallowed transport errors (K-talos-first-boot). Fully offline:
3
+ * fake-process.ts fakes `bao`/`talosctl`, nothing real spawns.
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import { describe, it } from 'node:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
9
+ import { type FakeCall, fakeSpawner } from './fake-process.ts';
10
+ import {
11
+ diffClusterHealth,
12
+ readClusterHealth,
13
+ reconcileClusterHealth,
14
+ } from './talos-cluster-health.ts';
15
+
16
+ const TARGET = { cluster: 'c1', mount: 'talos-c1' };
17
+ const props = () => ({ controlPlaneNodes: ['198.51.100.10'], target: TARGET });
18
+
19
+ const run = <A, E>(
20
+ effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
21
+ handler: (c: FakeCall) => { stdout?: string; stderr?: string; exitCode?: number },
22
+ calls: FakeCall[] = [],
23
+ ) =>
24
+ Effect.runPromise(
25
+ Effect.provideService(
26
+ effect,
27
+ ChildProcessSpawner.ChildProcessSpawner,
28
+ fakeSpawner(handler, calls),
29
+ ),
30
+ );
31
+
32
+ const baoOk = (call: FakeCall) =>
33
+ call.command === 'bao'
34
+ ? { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) }
35
+ : undefined;
36
+
37
+ /** `talosctl health` ran and reported unhealthy — the one case this family may call "not healthy". */
38
+ const unhealthy = (call: FakeCall) =>
39
+ baoOk(call) ?? { exitCode: 1, stderr: 'kube-proxy not ready' };
40
+
41
+ /** `talosctl health` ran and reported healthy. */
42
+ const healthy = (call: FakeCall) => baoOk(call) ?? {};
43
+
44
+ /** `bao` itself fails — a transport/credential problem, not a cluster-health verdict. */
45
+ const vaultDown = (call: FakeCall) =>
46
+ call.command === 'bao' ? { exitCode: 1, stderr: 'permission denied' } : {};
47
+
48
+ describe('readClusterHealth', () => {
49
+ it('reports healthy:false when talosctl health ran and exited non-zero', async () => {
50
+ const result = await run(readClusterHealth(props()), unhealthy);
51
+ assert.equal(result.healthy, false);
52
+ });
53
+
54
+ it('reports healthy:true when talosctl health exits zero', async () => {
55
+ const result = await run(readClusterHealth(props()), healthy);
56
+ assert.equal(result.healthy, true);
57
+ });
58
+
59
+ it('propagates a vault/transport failure instead of reporting healthy:false', async () => {
60
+ await assert.rejects(
61
+ run(readClusterHealth(props()), vaultDown),
62
+ (error: unknown) => error instanceof Error && error.message.includes('permission denied'),
63
+ );
64
+ });
65
+ });
66
+
67
+ describe('diffClusterHealth', () => {
68
+ it('plans update when the cluster genuinely reports unhealthy', async () => {
69
+ const result = await run(
70
+ diffClusterHealth(props(), {
71
+ controlPlaneNodes: '198.51.100.10',
72
+ healthy: false,
73
+ workerNodes: '',
74
+ }),
75
+ unhealthy,
76
+ );
77
+ assert.equal(result?.action, 'update');
78
+ });
79
+
80
+ it('propagates a vault/transport failure rather than planning update silently', async () => {
81
+ await assert.rejects(
82
+ run(
83
+ diffClusterHealth(props(), {
84
+ controlPlaneNodes: '198.51.100.10',
85
+ healthy: false,
86
+ workerNodes: '',
87
+ }),
88
+ vaultDown,
89
+ ),
90
+ );
91
+ });
92
+ });
93
+
94
+ describe('check — I1 fix: --nodes names ONE contact node, not the whole cluster', () => {
95
+ it('targets only the first control-plane node with --nodes/--endpoints, but lists all of them under --control-plane-nodes', async () => {
96
+ const calls: FakeCall[] = [];
97
+ await run(
98
+ readClusterHealth({
99
+ controlPlaneNodes: ['198.51.100.10', '198.51.100.11', '198.51.100.12'],
100
+ target: TARGET,
101
+ }),
102
+ healthy,
103
+ calls,
104
+ );
105
+ const talosctlCall = calls.find((c) => c.command === 'talosctl');
106
+ assert.ok(talosctlCall);
107
+ // ⛔ talosctl v1.13.8, measured: `health --nodes <ip1>,<ip2>` exits with "command \"health\" is
108
+ // not supported with multiple nodes" — exactly one contact node may ever reach --nodes.
109
+ const nodesIdx = talosctlCall.args.indexOf('--nodes');
110
+ assert.equal(talosctlCall.args[nodesIdx + 1], '198.51.100.10');
111
+ const endpointsIdx = talosctlCall.args.indexOf('--endpoints');
112
+ assert.equal(talosctlCall.args[endpointsIdx + 1], '198.51.100.10');
113
+ const cpIdx = talosctlCall.args.indexOf('--control-plane-nodes');
114
+ assert.equal(talosctlCall.args[cpIdx + 1], '198.51.100.10,198.51.100.11,198.51.100.12');
115
+ });
116
+
117
+ it('fails closed instead of spawning talosctl when controlPlaneNodes is empty', async () => {
118
+ await assert.rejects(
119
+ run(readClusterHealth({ controlPlaneNodes: [], target: TARGET }), healthy),
120
+ (error: unknown) =>
121
+ error instanceof Error && error.message.includes('controlPlaneNodes is empty'),
122
+ );
123
+ });
124
+ });
125
+
126
+ describe('reconcileClusterHealth', () => {
127
+ it('wraps a TalosError with a "cluster not healthy" message', async () => {
128
+ await assert.rejects(
129
+ run(reconcileClusterHealth(props()), unhealthy),
130
+ (error: unknown) => error instanceof Error && error.message.includes('cluster not healthy'),
131
+ );
132
+ });
133
+
134
+ it('propagates a vault/transport failure WITHOUT the "cluster not healthy" mislabel', async () => {
135
+ await assert.rejects(
136
+ run(reconcileClusterHealth(props()), vaultDown),
137
+ (error: unknown) =>
138
+ error instanceof Error &&
139
+ error.message.includes('permission denied') &&
140
+ !error.message.includes('cluster not healthy'),
141
+ );
142
+ });
143
+ });
@@ -6,6 +6,22 @@
6
6
  *
7
7
  * ⚠️ PLAN READS USE A SHORT TIMEOUT SO `plan` DOES NOT BLOCK TWENTY MINUTES. Reconcile uses the
8
8
  * declared timeout — that is the deploy gate, not the plan gate.
9
+ *
10
+ * ⛔ THIS RESOURCE ASSUMES A CNI IS ALREADY RUNNING (K-talos-first-boot, 2026-09-26). The default
11
+ * `talosctl health` suite checks nodes Ready, kube-proxy and CoreDNS, and Talos's own source
12
+ * comment says these wait on the CNI (REASONED, `pkg/cluster/check/default.go:28-60,89-91`;
13
+ * docs/plans/2026-09-26-talos-stack-first-boot.md's "CNI ordering" section). A consuming stack's
14
+ * `after` MUST include the Cilium install step, not just `Talos.Bootstrap`/`Talos.Kubeconfig` —
15
+ * declaring this row directly after bootstrap deadlocks into the `--wait-timeout` on every first
16
+ * deploy, because Argo CD itself needs pod networking before it can install Cilium. Row order:
17
+ * Bootstrap → Kubeconfig → Cilium (inline manifests in the seeded machine config, D-S2) →
18
+ * `Talos.ClusterHealth`.
19
+ * ⛔ NO SWALLOWED TRANSPORT ERRORS (K-talos-first-boot) — the shipped `read` caught EVERY error
20
+ * from `check()`, including a `mintTalosconfig`/`bao` failure, into a plain `healthy: false`. A
21
+ * vault outage would then read as "cluster not healthy yet" — an operator chasing a Cilium
22
+ * rollout would never learn the real problem was vault. Both `read` and `reconcile` now only
23
+ * catch `TalosError` (`talosctl health` itself ran and exited non-zero — the one case this
24
+ * family may honestly call "not healthy"); anything else propagates untouched.
9
25
  */
10
26
  import { Resource } from 'alchemy';
11
27
  import { isResolved } from 'alchemy/Diff';
@@ -14,7 +30,7 @@ import * as Provider from 'alchemy/Provider';
14
30
  import * as Effect from 'effect/Effect';
15
31
  import { mintTalosconfig } from './credentials.ts';
16
32
  import type { TalosRequirements, WithTarget } from './resource.ts';
17
- import { talosctl } from './talosctl.ts';
33
+ import { TalosError, talosctl } from './talosctl.ts';
18
34
 
19
35
  export interface ClusterHealthProps extends WithTarget {
20
36
  /** Control-plane node IPs passed to `--control-plane-nodes`. */
@@ -27,7 +43,11 @@ export interface ClusterHealthProps extends WithTarget {
27
43
  * ⚠️ REASONED default — not measured here.
28
44
  */
29
45
  waitTimeout?: string;
30
- /** Bootstrap and kubeconfig should complete before health is enforced. */
46
+ /**
47
+ * Ordering edge. ⛔ MUST reach past `Talos.Bootstrap`/`Talos.Kubeconfig` all the way through the
48
+ * Cilium CNI install (this file's own header, "CNI ordering") — the default health checks wait
49
+ * on kube-proxy and CoreDNS, both of which wait on a CNI that does not exist yet at bootstrap.
50
+ */
31
51
  after?: readonly unknown[];
32
52
  }
33
53
 
@@ -59,45 +79,88 @@ const healthArgs = (props: ClusterHealthProps, waitTimeout: string) => {
59
79
  return args;
60
80
  };
61
81
 
82
+ /**
83
+ * ⛔ WRAPPED IN `Effect.scoped` (K-A3, 2026-09-26) — `mintTalosconfig` contributes `Scope.Scope`
84
+ * to its own return type rather than closing its scope internally (the C1 fix, credentials.ts's
85
+ * own header); both `read` and `reconcile` call this one function, so wrapping it here covers
86
+ * both callers.
87
+ * ⛔ I1 FIX (LAND red team, 2026-09-26) — `--nodes` NAMES ONE CONTACT NODE, NOT THE WHOLE CLUSTER.
88
+ * MEASURED against local talosctl v1.13.8 (unroutable TEST-NET addresses): `talosctl health
89
+ * --nodes <ip1>,<ip2>` fails with `command "health" is not supported with multiple nodes` — the
90
+ * shipped code passed EVERY control-plane node as `--nodes`, so with decision 66's 3-node layout
91
+ * this command could never succeed even against a genuinely healthy cluster: `read` always
92
+ * reported `healthy:false`, `diff` always planned `update`, and `reconcile` always failed with a
93
+ * misleading "not healthy within Nm". Fixed: the first control-plane node is the sole `--nodes`/
94
+ * `--endpoints` contact point (talosctl proxies the actual check through it); the FULL node lists
95
+ * still go to `--control-plane-nodes`/`--worker-nodes` via `healthArgs`, which is what `talosctl
96
+ * health` actually checks the health OF.
97
+ */
62
98
  const check = (props: ClusterHealthProps, waitTimeout: string) =>
63
- Effect.gen(function* () {
64
- const credential = yield* mintTalosconfig(props.target);
65
- yield* talosctl(healthArgs(props, waitTimeout), {
66
- nodes: [...props.controlPlaneNodes],
67
- talosconfigPath: credential.talosconfigPath,
68
- });
69
- return {
70
- controlPlaneNodes: nodeCsv(props.controlPlaneNodes),
71
- healthy: true,
72
- workerNodes: nodeCsv(props.workerNodes),
73
- };
74
- });
99
+ Effect.scoped(
100
+ Effect.gen(function* () {
101
+ const credential = yield* mintTalosconfig(props.target);
102
+ const contact = props.controlPlaneNodes[0];
103
+ if (contact === undefined) {
104
+ return yield* Effect.fail(
105
+ new Error(
106
+ `Talos.ClusterHealth ${props.target.cluster}: controlPlaneNodes is empty — need at ` +
107
+ 'least one node to contact.',
108
+ ),
109
+ );
110
+ }
111
+ yield* talosctl(healthArgs(props, waitTimeout), {
112
+ endpoints: [contact],
113
+ nodes: [contact],
114
+ talosconfigPath: credential.talosconfigPath,
115
+ });
116
+ return {
117
+ controlPlaneNodes: nodeCsv(props.controlPlaneNodes),
118
+ healthy: true,
119
+ workerNodes: nodeCsv(props.workerNodes),
120
+ };
121
+ }),
122
+ );
123
+
124
+ /**
125
+ * ⛔ CATCHES ONLY `TalosError` (K-talos-first-boot) — a run of `talosctl health` that itself
126
+ * completed and exited non-zero, which is the only outcome this family may honestly call "not
127
+ * healthy yet". Anything else (a `mintTalosconfig`/`bao` failure, a JS defect) propagates instead
128
+ * of masquerading as an unhealthy cluster — see this file's own header.
129
+ */
130
+ const isTalosError = (cause: unknown): cause is TalosError => cause instanceof TalosError;
75
131
 
76
- const read = (props: ClusterHealthProps) =>
132
+ /** ★ EXPORTED for talos-cluster-health.test.ts — see talos-bootstrap.ts's own note on the pattern. */
133
+ export const readClusterHealth = (props: ClusterHealthProps) =>
77
134
  check(props, '5s').pipe(
78
- Effect.orElseSucceed(() => ({
79
- controlPlaneNodes: nodeCsv(props.controlPlaneNodes),
80
- healthy: false,
81
- workerNodes: nodeCsv(props.workerNodes),
82
- })),
135
+ Effect.catchIf(isTalosError, () =>
136
+ Effect.succeed({
137
+ controlPlaneNodes: nodeCsv(props.controlPlaneNodes),
138
+ healthy: false,
139
+ workerNodes: nodeCsv(props.workerNodes),
140
+ }),
141
+ ),
83
142
  );
84
143
 
85
- const diff = (news: Input<ClusterHealthProps>, output: ClusterHealthAttributes | undefined) =>
144
+ export const diffClusterHealth = (
145
+ news: Input<ClusterHealthProps>,
146
+ output: ClusterHealthAttributes | undefined,
147
+ ) =>
86
148
  Effect.gen(function* () {
87
149
  if (output === undefined || !isResolved(news)) return undefined;
88
- const live = yield* read(news);
150
+ const live = yield* readClusterHealth(news);
89
151
  if (live.healthy) return { action: 'noop' } as const;
90
152
  return { action: 'update' } as const;
91
153
  });
92
154
 
93
- const reconcile = (props: ClusterHealthProps) =>
155
+ export const reconcileClusterHealth = (props: ClusterHealthProps) =>
94
156
  check(props, props.waitTimeout ?? '20m0s').pipe(
95
- Effect.mapError(
96
- (cause) =>
157
+ Effect.catchIf(isTalosError, (cause) =>
158
+ Effect.fail(
97
159
  new Error(
98
160
  `Talos.ClusterHealth ${props.target.cluster}: cluster not healthy within ` +
99
161
  `${props.waitTimeout ?? '20m0s'} — ${String(cause)}`,
100
162
  ),
163
+ ),
101
164
  ),
102
165
  );
103
166
 
@@ -109,10 +172,10 @@ const handlers = {
109
172
  }: {
110
173
  news: Input<ClusterHealthProps>;
111
174
  output: ClusterHealthAttributes | undefined;
112
- }) => diff(news, output),
175
+ }) => diffClusterHealth(news, output),
113
176
  list: () => Effect.succeed([]),
114
- read: ({ olds }: { olds: ClusterHealthProps }) => read(olds),
115
- reconcile: ({ news }: { news: ClusterHealthProps }) => reconcile(news),
177
+ read: ({ olds }: { olds: ClusterHealthProps }) => readClusterHealth(olds),
178
+ reconcile: ({ news }: { news: ClusterHealthProps }) => reconcileClusterHealth(news),
116
179
  };
117
180
 
118
181
  export const TalosClusterHealthProvider = () =>
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `Talos.MachineConfig`'s typed failures — split out of talos-machine-config.ts (2026-09-26,
3
+ * K-A3) for the same reason proxmox/credential-errors.ts gives: keep a class's own header out of
4
+ * the 250-line budget of the file that raises it.
5
+ */
6
+ import * as Data from 'effect/Data';
7
+ import type { ApplyMode } from './resource.ts';
8
+
9
+ /**
10
+ * The OpenBao KV content at `mount/key` does not hash to the pinned `configDigest`.
11
+ *
12
+ * ★ FAIL CLOSED, NO SPAWN — docs/plans/2026-09-26-talos-secrets-flow.md's O-A: "a poisoned or
13
+ * fat-fingered KV write cannot silently reach a node". Raised BEFORE any talosctl process
14
+ * starts, so a mismatch never sends an unpinned config anywhere — see reconcile() in
15
+ * talos-machine-config.ts, which checks this ahead of minting the talosconfig credential too.
16
+ * ⛔ NEITHER DIGEST IN THIS MESSAGE IS THE CONFIG. Both are sha256 hex digests — safe to log,
17
+ * safe to persist — never the KV content itself.
18
+ */
19
+ export class TalosConfigDigestMismatch extends Data.TaggedError('TalosConfigDigestMismatch')<{
20
+ readonly mount: string;
21
+ readonly key: string;
22
+ readonly expected: string;
23
+ readonly got: string;
24
+ }> {
25
+ override get message(): string {
26
+ return (
27
+ `${this.mount}/${this.key}: live content digest ${this.got} does not match the pinned ` +
28
+ `configDigest ${this.expected}. Fail closed — nothing was sent to talosctl. Either the KV ` +
29
+ 'value was rewritten without a matching PR, or the pinned digest is stale; check which one ' +
30
+ 'moved before re-pinning either side.'
31
+ );
32
+ }
33
+ }
34
+
35
+ /**
36
+ * `apply-config` returned success but the live machineconfig digest never matched the pin within
37
+ * the bounded poll (machine-config-poll.ts). See
38
+ * docs/plans/2026-09-26-talos-stack-first-boot.md's "Converged" section.
39
+ *
40
+ * ⛔ THIS IS THE REPLACEMENT FOR A SILENT `converged: false`. The shipped code let a read
41
+ * failure during convergence checking disguise itself as "not converged yet" forever
42
+ * (`orElseSucceed`); this type makes cap expiry a refusal an operator sees, not a plan that
43
+ * quietly retries the same apply on every future run.
44
+ */
45
+ export class TalosConvergenceTimeout extends Data.TaggedError('TalosConvergenceTimeout')<{
46
+ readonly node: string;
47
+ readonly mode: ApplyMode;
48
+ readonly attempts: number;
49
+ }> {
50
+ override get message(): string {
51
+ return (
52
+ `${this.node}: apply-config (--mode ${this.mode}) was accepted but the live machineconfig ` +
53
+ `digest never matched the pin within ${this.attempts} bounded poll attempts. Refusing to ` +
54
+ 'report converged — hand this node to the operator rather than silently pass.'
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * `Talos.Bootstrap` reconcile found state already recording `bootstrapped: true`, and a live
61
+ * check against etcd could not reconfirm it — either the read itself failed (`detail` carries the
62
+ * propagated cause), or it succeeded and came back with no members.
63
+ *
64
+ * ★ K-talos-first-boot's "once means once" — docs/plans/2026-09-26-talos-stack-first-boot.md's own
65
+ * section of that name. EITHER cause raises this SAME typed error, and both ways `talosctl
66
+ * bootstrap` is never spawned a second time: re-running it against a node that lost its etcd
67
+ * data (a VM replace, a reset) forms a second, isolated single-member etcd cluster instead of
68
+ * rejoining the existing one (REASONED, `v1alpha1_server.go:441` — Talos's only server-side
69
+ * guard is a non-empty data directory, not "did this cluster already bootstrap"). That is split
70
+ * brain, and nothing here can tell a genuinely reset node (needs its own operator-run recovery)
71
+ * apart from a merely slow or unreachable read (needs a retry) — so both refuse alike and hand
72
+ * the node to a human instead of guessing.
73
+ */
74
+ export class TalosReBootstrapRefused extends Data.TaggedError('TalosReBootstrapRefused')<{
75
+ readonly node: string;
76
+ readonly detail: string;
77
+ }> {
78
+ override get message(): string {
79
+ return (
80
+ `${this.node}: state already recorded a successful bootstrap, and a live check could not ` +
81
+ `reconfirm it (${this.detail}). Refusing to run 'talosctl bootstrap' again — that risks a ` +
82
+ 'second, isolated etcd cluster on a reset node. This needs an operator, not a reconcile.'
83
+ );
84
+ }
85
+ }