@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,57 @@
1
+ /**
2
+ * Bounded-poll-per-mode acceptance tests from docs/plans/2026-09-26-talos-stack-first-boot.md's
3
+ * "Converged" section — exercised through `reconcileMachineConfig` with `FAST_POLL` (a test-only
4
+ * seam, see machine-config-poll.ts's own header) so the ~10s/~2m production caps never run here.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
8
+ import {
9
+ CONFIG_TEXT,
10
+ FAST_POLL,
11
+ dispatcher,
12
+ hasTag,
13
+ props,
14
+ run,
15
+ wrapperFor,
16
+ } from './machine-config-fixtures.ts';
17
+ import type { ApplyMode } from './resource.ts';
18
+ import { reconcileMachineConfig } from './talos-machine-config.ts';
19
+
20
+ describe('reconcileMachineConfig — bounded poll per mode', () => {
21
+ const rebootish: ApplyMode = 'reboot';
22
+ const immediate: ApplyMode = 'no-reboot';
23
+
24
+ it("no-reboot converges via a single successful read-back ('read-back')", async () => {
25
+ const out = await run(
26
+ reconcileMachineConfig(props({ mode: immediate }), undefined, FAST_POLL),
27
+ dispatcher({}),
28
+ );
29
+ assert.equal(out.converged, 'read-back');
30
+ });
31
+
32
+ it("reboot/auto tolerate a transient miss then converge ('accepted')", async () => {
33
+ let attempt = 0;
34
+ const out = await run(
35
+ reconcileMachineConfig(props({ mode: rebootish }), undefined, FAST_POLL),
36
+ dispatcher({
37
+ liveWrapper: () => {
38
+ attempt += 1;
39
+ return attempt < 2
40
+ ? { exitCode: 1, stderr: 'node rebooting' }
41
+ : { stdout: wrapperFor(CONFIG_TEXT) };
42
+ },
43
+ }),
44
+ );
45
+ assert.equal(out.converged, 'accepted');
46
+ });
47
+
48
+ it('raises TalosConvergenceTimeout, never a silent pass, when the cap expires', async () => {
49
+ await assert.rejects(
50
+ run(
51
+ reconcileMachineConfig(props({ mode: rebootish }), undefined, FAST_POLL),
52
+ dispatcher({ liveWrapper: () => ({ exitCode: 1, stderr: 'still unreachable' }) }),
53
+ ),
54
+ hasTag('TalosConvergenceTimeout'),
55
+ );
56
+ });
57
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Post-apply convergence for `Talos.MachineConfig` — split out of talos-machine-config.ts
3
+ * (2026-09-26, K-A3) once the bounded-poll logic needed its own header. See
4
+ * docs/plans/2026-09-26-talos-stack-first-boot.md, "Converged" and "Bootstrap" sections.
5
+ *
6
+ * ⛔ NO SILENT `converged: false` HERE. A single attempt's own transport failure is swallowed
7
+ * ONLY inside the bounded loop below — a rebooting node's API is legitimately unreachable for a
8
+ * while — but the loop AS A WHOLE never swallows: running out of attempts always raises
9
+ * `TalosConvergenceTimeout`, never a quiet `false`. That distinction is read()'s job, not
10
+ * reconcile's — see talos-machine-config.ts.
11
+ * ⚠️ SHORT INTERVALS, A LOW CAP, NEVER ONE LONG SLEEP — the house timeout rule, and the specific
12
+ * reason talosctl's own `health` gate has a 20-minute `--wait-timeout`: a single blocking wait
13
+ * that long would hide whether THIS process is still alive from anything watching it.
14
+ */
15
+ import * as Effect from 'effect/Effect';
16
+ import type { ApplyMode } from './resource.ts';
17
+ import { TalosConvergenceTimeout } from './talos-errors.ts';
18
+
19
+ /**
20
+ * Modes whose apply can trigger a full install + reboot. REASONED, Talos v1.13 CLI reference.
21
+ * ⛔ `'staged'` is deliberately absent — resource.ts's own header explains why (fix-first #2).
22
+ */
23
+ const isRebootish = (mode: ApplyMode) => mode === 'reboot' || mode === 'auto';
24
+
25
+ /**
26
+ * ★ TWO POLICIES, ONE PER MODE CLASS — REASONED, no live cluster to time this against yet.
27
+ * `no-reboot` never drops the API, so a handful of quick retries covers only ordinary
28
+ * config-propagation lag. `reboot`/`auto` can drop the API for a full boot cycle, so the cap is
29
+ * longer and the interval coarser. Both stay well inside the house rule above.
30
+ */
31
+ export type PollPolicy = { readonly intervalMs: number; readonly maxAttempts: number };
32
+
33
+ const POLICY: { readonly immediate: PollPolicy; readonly rebootish: PollPolicy } = {
34
+ immediate: { intervalMs: 2_000, maxAttempts: 5 }, // ~10s cap
35
+ rebootish: { intervalMs: 5_000, maxAttempts: 24 }, // ~2m cap
36
+ };
37
+
38
+ /**
39
+ * Run `check` until it reports `true` or `maxAttempts` is spent, sleeping `intervalMs` between
40
+ * attempts. A per-attempt failure counts as "not yet" and does not end the loop early.
41
+ */
42
+ const pollUntilMatch = <E, R>(
43
+ check: Effect.Effect<boolean, E, R>,
44
+ policy: { readonly intervalMs: number; readonly maxAttempts: number },
45
+ ): Effect.Effect<boolean, never, R> =>
46
+ Effect.gen(function* () {
47
+ for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
48
+ const matched = yield* check.pipe(Effect.orElseSucceed(() => false));
49
+ if (matched) return true;
50
+ if (attempt < policy.maxAttempts) yield* Effect.sleep(policy.intervalMs);
51
+ }
52
+ return false;
53
+ });
54
+
55
+ /**
56
+ * Confirm convergence per `mode`'s policy, or fail with `TalosConvergenceTimeout`.
57
+ *
58
+ * Returns which CLAIM was proven, never a boolean — `'read-back'` for the immediate policy
59
+ * (no-reboot), `'accepted'` for the reboot-tolerant one (reboot/auto): state records which kind of
60
+ * confirmation actually happened, per the design doc's `converged` field.
61
+ *
62
+ * ⚠️ `policies` IS A TEST SEAM, NOT A PROP. `talos-machine-config.ts` never passes it, so
63
+ * production always runs the REASONED defaults above; tests pass tiny intervals so a bounded
64
+ * poll test finishes in milliseconds instead of the real ~10s/~2m caps.
65
+ */
66
+ export const confirmConverged = <E, R>(
67
+ node: string,
68
+ mode: ApplyMode,
69
+ check: Effect.Effect<boolean, E, R>,
70
+ policies: { readonly immediate: PollPolicy; readonly rebootish: PollPolicy } = POLICY,
71
+ ): Effect.Effect<'read-back' | 'accepted', TalosConvergenceTimeout, R> =>
72
+ Effect.gen(function* () {
73
+ const rebootish = isRebootish(mode);
74
+ const policy = rebootish ? policies.rebootish : policies.immediate;
75
+ const matched = yield* pollUntilMatch(check, policy);
76
+ if (!matched) {
77
+ return yield* Effect.fail(
78
+ new TalosConvergenceTimeout({ attempts: policy.maxAttempts, mode, node }),
79
+ );
80
+ }
81
+ return rebootish ? 'accepted' : 'read-back';
82
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `Talos.MachineConfig`'s read path — split out of talos-machine-config.ts (2026-09-26, fix-first
3
+ * #1/#2 on PR 307's red team) once the ownership-aware read needed its own header, the same reason
4
+ * machine-config-poll.ts and talos-errors.ts were split out before it. Only `import type` comes
5
+ * back from talos-machine-config.ts, so there is no runtime cycle between the two files.
6
+ */
7
+ import { Unowned } from 'alchemy/AdoptPolicy';
8
+ import { isResolved } from 'alchemy/Diff';
9
+ import type { Input } from 'alchemy/Input';
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Result from 'effect/Result';
12
+ import { mintTalosconfig } from './credentials.ts';
13
+ import type { MachineConfigAttributes, MachineConfigProps } from './talos-machine-config.ts';
14
+ import { talosctl } from './talosctl.ts';
15
+ import { configDigest, extractMachineConfigSpec } from './values.ts';
16
+
17
+ /**
18
+ * Single live read: `talosctl get machineconfig v1alpha1`, extract `spec`, hash it. No retry, no
19
+ * swallow.
20
+ *
21
+ * ⛔ THE ID IS NEVER OMITTED (fix-first #2, PR 307 red team). An unfiltered `get machineconfig`
22
+ * lists `persistent` AND `v1alpha1`, sorted by id — `persistent` first — so a caller that does not
23
+ * name `v1alpha1` gets whichever the SDK's `doc[0]` happens to be, never provably the applied
24
+ * config (values.ts's own multi-document guard is the second half of this fix).
25
+ * ★ `endpoints: [props.node]` (fix-first #5, same review) — without it talosctl falls back to the
26
+ * talosconfig context's own endpoints, which may be a control-plane VIP that only comes up AFTER
27
+ * bootstrap; every pre-bootstrap call in this file targets the node directly instead.
28
+ */
29
+ export const liveSpecDigest = (props: MachineConfigProps, talosconfigPath: string) =>
30
+ talosctl(['get', 'machineconfig', 'v1alpha1', '-o', 'yaml'], {
31
+ endpoints: [props.node],
32
+ nodes: [props.node],
33
+ talosconfigPath,
34
+ }).pipe(
35
+ Effect.flatMap((wrapperYaml) => {
36
+ const spec = extractMachineConfigSpec(wrapperYaml);
37
+ return spec === undefined
38
+ ? Effect.fail(
39
+ new Error(
40
+ `${props.node}: get machineconfig v1alpha1 returned no string 'spec' field — see ` +
41
+ 'values.ts extractMachineConfigSpec for the expected shape.',
42
+ ),
43
+ )
44
+ : Effect.succeed(configDigest(spec));
45
+ }),
46
+ );
47
+
48
+ /**
49
+ * ★ MAINTENANCE-MODE PROBE (fix-first #1, PR 307 red team). A freshly booted node has no
50
+ * talosconfig auth yet, so the AUTHENTICATED read above fails there exactly the same way it would
51
+ * against a genuinely unreachable node — the two must not be confused (a real transport failure
52
+ * silently read as "not created" would let a cold-start plan skip straight past a broken node).
53
+ * `--insecure` distinguishes them: REASONED from the published Talos quickstart
54
+ * (`talosctl -n <ip> version --insecure` is the documented way to confirm a maintenance-mode node
55
+ * before its first apply) — not measured on a live cluster. Success here means "nothing applied
56
+ * yet", never "converged", and never returns config content.
57
+ */
58
+ const probeMaintenanceMode = (props: MachineConfigProps, talosconfigPath: string) =>
59
+ talosctl(['version'], {
60
+ endpoints: [props.node],
61
+ insecure: true,
62
+ nodes: [props.node],
63
+ talosconfigPath,
64
+ }).pipe(
65
+ Effect.as(true),
66
+ Effect.orElseSucceed(() => false),
67
+ );
68
+
69
+ /**
70
+ * ⛔ NO SWALLOWING. A transport failure here propagates as its own error (TalosError or the
71
+ * shape error above) — never as `converged: false`. A false only means "read succeeded, and
72
+ * genuinely differs" (docs/plans/2026-09-26-talos-stack-first-boot.md, acceptance test 3).
73
+ * ⛔ THREE-WAY COLD-START ANSWER, NOT TWO (fix-first #1). Alchemy's engine calls this exact
74
+ * function as its adoption probe with no prior state (alchemy beta.79 Plan.ts:1302-1318) and
75
+ * again to recover an interrupted create (`:1413-1450`) — both hand it nothing to distinguish
76
+ * "not created" from "already configured". Answering only "attrs or error" (the shipped shape)
77
+ * meant a maintenance-mode node's expected auth failure aborted the plan instead of reaching
78
+ * CREATE, and a genuinely different node's matching-cluster-CA-but-wrong-content read landed as a
79
+ * plain, unbranded attrs object — a silent adopt with no `adopt()`, breaking first-boot acceptance
80
+ * test 1 ("zero adopts"). Now: authenticated read fails but the maintenance probe succeeds →
81
+ * `undefined` (not created); authenticated read succeeds and the digest matches → plain attrs
82
+ * (ours); succeeds and differs → `Unowned` (exists, not proven ours — the digest match IS this
83
+ * family's ownership proof, not a state-row lookup like `ownership/probe.ts`'s generic
84
+ * `ownedRead`, which answers a different question: "did OUR state row create this name", not
85
+ * "does this node hold OUR pinned content"); both reads fail → the authenticated error propagates.
86
+ *
87
+ * ★ EXPORTED (like proxmox/ceph-daemon.ts's `reconcileDaemon`) so tests call it directly with a
88
+ * fake `ChildProcessSpawner` instead of driving it through the full Alchemy engine.
89
+ */
90
+ export const readMachineConfig = (props: MachineConfigProps) =>
91
+ Effect.scoped(
92
+ Effect.gen(function* () {
93
+ const credential = yield* mintTalosconfig(props.target);
94
+ const outcome = yield* Effect.result(liveSpecDigest(props, credential.talosconfigPath));
95
+ if (Result.isFailure(outcome)) {
96
+ const reachable = yield* probeMaintenanceMode(props, credential.talosconfigPath);
97
+ return reachable ? undefined : yield* Effect.fail(outcome.failure);
98
+ }
99
+ const attrs = {
100
+ configDigest: props.configDigest,
101
+ converged: outcome.success === props.configDigest ? ('read-back' as const) : false,
102
+ mode: props.mode ?? 'auto',
103
+ node: props.node,
104
+ } satisfies MachineConfigAttributes;
105
+ return attrs.converged === false ? Unowned(attrs) : attrs;
106
+ }),
107
+ );
108
+
109
+ export const diffMachineConfig = (
110
+ news: Input<MachineConfigProps>,
111
+ output: MachineConfigAttributes | undefined,
112
+ ) =>
113
+ Effect.gen(function* () {
114
+ if (output === undefined || !isResolved(news)) return undefined;
115
+ const live = yield* readMachineConfig(news);
116
+ if (live !== undefined && live.converged !== false) return { action: 'noop' } as const;
117
+ return { action: 'update' } as const;
118
+ });
@@ -29,3 +29,22 @@ export type TalosRequirements = ChildProcessSpawner.ChildProcessSpawner;
29
29
 
30
30
  /** Every resource names which cluster's OpenBao mount it uses. */
31
31
  export type WithTarget = { target: TalosTarget };
32
+
33
+ /**
34
+ * `talosctl apply-config -m/--mode`. Lives here, not in talos-machine-config.ts, so
35
+ * talos-errors.ts can name it in `TalosConvergenceTimeout` without importing that file back
36
+ * (machine-config-poll.ts -> talos-errors.ts -> talos-machine-config.ts would cycle).
37
+ *
38
+ * ★ K-A3 DROPPED THE `insecure` PROP THAT USED TO SIT BESIDE THIS TYPE. A fixed `insecure` prop
39
+ * fails in both directions (`true` breaks every later update, `false` breaks the very first
40
+ * apply against a node with no talosconfig auth yet), so `Talos.MachineConfig` now derives it
41
+ * itself: CREATE (`output === undefined`) applies `--insecure`, UPDATE never does.
42
+ * ⛔ `'staged'` AND `'try'` ARE DROPPED (fix-first #2, PR 307 red team). `try` reverts itself after
43
+ * its timeout, so a bounded poll that later reads the reverted config would see it never match
44
+ * the pin and re-run `apply-config` on every deploy — the poll would be "confirming" a change
45
+ * `try` had already undone. `staged` defers the write to the NEXT reboot rather than applying it,
46
+ * which `isRebootish` (machine-config-poll.ts) cannot honestly claim `'accepted'` for without a
47
+ * reboot ever happening — the reboot for a staged config is a separate, later operator action
48
+ * this package does not drive. Both need their own design work, not a poll-policy guess.
49
+ */
50
+ export type ApplyMode = 'auto' | 'no-reboot' | 'reboot';
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Acceptance test 2 from docs/plans/2026-09-26-talos-secrets-flow.md: "no talos prop, attribute or
3
+ * argv carries config bytes; state fixtures contain no PEM or token strings (grep gate in the
4
+ * family's tests)." PR 307 shipped every other acceptance test from the design suite but not this
5
+ * one (PR 307 red team, minor #4) — the builder's "every acceptance test covered" claim was wrong.
6
+ *
7
+ * ★ A POSITIVE CONTROL WOULD DEFEAT THE PURPOSE HERE. Unlike lxc-interior.test.ts's "prove PVE
8
+ * really has no such endpoint", this file's job is to prove NOTHING in this family's own source
9
+ * ever grew a byte-carrying field — there is no "real" case where a match should be found. The
10
+ * props/attributes shape checks are the load-bearing half: they fail loudly if a `config` or
11
+ * `configFile` field is ever added back next to `configKey`/`configDigest`.
12
+ */
13
+ import { describe, it } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { readFileSync, readdirSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import type { MachineConfigAttributes, MachineConfigProps } from './talos-machine-config.ts';
18
+
19
+ const TALOS_DIR = import.meta.dir;
20
+
21
+ /** Every `.ts` file this family owns — source, tests and fixtures alike. Never node_modules. */
22
+ const talosFiles = () =>
23
+ readdirSync(TALOS_DIR)
24
+ .filter((name) => name.endsWith('.ts'))
25
+ .map((name) => join(TALOS_DIR, name));
26
+
27
+ describe('secrets gate — acceptance test 2', () => {
28
+ it('no file in this family embeds PEM material or a bootstrap-token shape', () => {
29
+ const pem = /-----BEGIN [A-Z ]+-----/;
30
+ // `talosctl gen secrets`' bootstrap token is a `<14>.<28>` lowercase-alnum id.secret pair
31
+ // (REASONED from the published config schema) — never generated anywhere in this family.
32
+ const bootstrapToken = /\b[a-z0-9]{14}\.[a-z0-9]{28}\b/;
33
+ for (const file of talosFiles()) {
34
+ const text = readFileSync(file, 'utf8');
35
+ assert.equal(pem.test(text), false, `${file}: looks like it embeds a PEM block`);
36
+ assert.equal(
37
+ bootstrapToken.test(text),
38
+ false,
39
+ `${file}: looks like it embeds a bootstrap token`,
40
+ );
41
+ }
42
+ });
43
+
44
+ it('MachineConfigProps carries a key and a digest, never config bytes', () => {
45
+ const sample: MachineConfigProps = {
46
+ configDigest: 'x',
47
+ configKey: 'nodes/10001',
48
+ node: '198.51.100.10',
49
+ target: { cluster: 'c1', mount: 'talos-c1' },
50
+ };
51
+ assert.deepEqual(Object.keys(sample).sort(), ['configDigest', 'configKey', 'node', 'target']);
52
+ });
53
+
54
+ it('MachineConfigAttributes carries a digest and a claim, never config bytes', () => {
55
+ const sample: MachineConfigAttributes = {
56
+ configDigest: 'x',
57
+ converged: false,
58
+ mode: 'auto',
59
+ node: '198.51.100.10',
60
+ };
61
+ assert.deepEqual(Object.keys(sample).sort(), ['configDigest', 'converged', 'mode', 'node']);
62
+ });
63
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `Talos.Bootstrap`'s etcd-members probe and split-brain guard — split out of talos-bootstrap.ts
3
+ * (LAND, 2026-09-26, I4 fix) once adding the guard pushed that file over the 250-line cap, the same
4
+ * reason machine-config-read.ts was split out of talos-machine-config.ts before it. Only
5
+ * `import type` comes back from talos-bootstrap.ts, so there is no runtime cycle between the files.
6
+ */
7
+ import * as Effect from 'effect/Effect';
8
+ import type { BootstrapProps } from './talos-bootstrap.ts';
9
+ import { TalosReBootstrapRefused } from './talos-errors.ts';
10
+ import { talosctl } from './talosctl.ts';
11
+
12
+ /**
13
+ * REASONED: `talosctl get etcdmembers -o json` should list members after bootstrap.
14
+ * ⚠️ NOT MEASURED — first live deploy confirms the resource type name.
15
+ * ⛔ NO SWALLOWING (K-talos-first-boot) — a transport failure propagates as its own error; only a
16
+ * SUCCESSFUL read may conclude presence or absence. Every caller decides for itself what a
17
+ * failure here means (talos-bootstrap.ts's `read`/`reconcile`, and `assertNoLivePeers` below), so
18
+ * it is not decided here.
19
+ */
20
+ export const isBootstrapped = (props: BootstrapProps, talosconfigPath: string) =>
21
+ talosctl(['get', 'etcdmembers', '-o', 'json'], {
22
+ nodes: [props.node],
23
+ talosconfigPath,
24
+ }).pipe(Effect.map((text) => text.includes('"id"') || text.includes('"member"')));
25
+
26
+ /**
27
+ * ⛔ I4 FIX (LAND red team, 2026-09-26) — THE CREATE PATH ONLY CHECKED THIS NODE. If the state row
28
+ * is lost (wrong `--stage`, an FQN rename, a wiped state store) while the cluster already lives on
29
+ * OTHER control-plane nodes, the cold-start adoption read on THIS node comes back empty, plan
30
+ * calls `create`, and `talosctl bootstrap` runs here too — a SECOND, isolated etcd cluster: split
31
+ * brain. Before bootstrapping, every OTHER declared control-plane node (`props.peers`) must show a
32
+ * successful, EMPTY etcd-members read — proof no cluster already exists anywhere this row knows
33
+ * about. A peer that already has members, or a peer whose read fails, refuses bootstrap exactly
34
+ * like an already-bootstrapped confirmation failure does (same typed error, same "hand it to a
35
+ * human" posture) — a genuinely fresh cluster has no peers with members yet, so this never blocks
36
+ * a real first boot.
37
+ * ⚠️ `peers` IS OPTIONAL, SO THIS GUARD IS OPT-IN — an existing single-control-plane declaration
38
+ * keeps working unguarded; a consuming stack with more than one control-plane node (decision 66:
39
+ * 3, one per TB4 node) SHOULD list the other two here to get the protection.
40
+ * ★ `Effect.all` over a mapped array, not `Effect.forEach` — oxlint's unicorn/no-array-for-each
41
+ * matches the method NAME and cannot tell Effect's from Array's (same pattern as
42
+ * openbao/cloudflare-permission-groups.ts).
43
+ */
44
+ export const assertNoLivePeers = (props: BootstrapProps, talosconfigPath: string) =>
45
+ Effect.all(
46
+ (props.peers ?? []).map((peer) =>
47
+ isBootstrapped({ ...props, node: peer }, talosconfigPath).pipe(
48
+ Effect.matchEffect({
49
+ onFailure: (cause) =>
50
+ Effect.fail(
51
+ new TalosReBootstrapRefused({
52
+ detail: `peer ${peer}: live read failed before bootstrap: ${String(cause)}`,
53
+ node: props.node,
54
+ }),
55
+ ),
56
+ onSuccess: (hasMembers) =>
57
+ hasMembers
58
+ ? Effect.fail(
59
+ new TalosReBootstrapRefused({
60
+ detail:
61
+ `peer ${peer} already reports etcd members — a cluster may already exist; ` +
62
+ 'refusing to bootstrap a second one',
63
+ node: props.node,
64
+ }),
65
+ )
66
+ : Effect.void,
67
+ }),
68
+ ),
69
+ ),
70
+ { discard: true },
71
+ );
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `Talos.Bootstrap` "once means once" — docs/plans/2026-09-26-talos-stack-first-boot.md's
3
+ * acceptance test 4, K-talos-first-boot. Fully offline: fake-process.ts fakes `bao`/`talosctl`,
4
+ * nothing real spawns.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
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 {
12
+ diffBootstrap as diff,
13
+ readBootstrap as read,
14
+ reconcileBootstrap as reconcile,
15
+ } from './talos-bootstrap.ts';
16
+
17
+ const TARGET = { cluster: 'c1', mount: 'talos-c1' };
18
+ const props = () => ({ node: '198.51.100.10', target: TARGET });
19
+
20
+ const bootstrapped = () => (call: FakeCall) => {
21
+ if (call.command === 'bao')
22
+ return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
23
+ if (call.args[0] === 'get') return { stdout: '{"id":"member-1"}' };
24
+ throw new Error(`unexpected call ${JSON.stringify(call)}`);
25
+ };
26
+
27
+ /** Absent until `bootstrap` is spawned, then present — models the CREATE path's before/after read. */
28
+ const notBootstrapped = () => {
29
+ let ranBootstrap = false;
30
+ return (call: FakeCall) => {
31
+ if (call.command === 'bao')
32
+ return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
33
+ if (call.args[0] === 'bootstrap') {
34
+ ranBootstrap = true;
35
+ return {};
36
+ }
37
+ if (call.args[0] === 'get') return { stdout: ranBootstrap ? '{"id":"member-1"}' : '[]' };
38
+ throw new Error(`unexpected call ${JSON.stringify(call)}`);
39
+ };
40
+ };
41
+
42
+ const etcdReadFails = () => (call: FakeCall) => {
43
+ if (call.command === 'bao')
44
+ return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
45
+ if (call.args[0] === 'get') return { exitCode: 1, stderr: 'dial tcp: connection refused' };
46
+ throw new Error(`unexpected call ${JSON.stringify(call)}`);
47
+ };
48
+
49
+ const run = <A, E>(
50
+ effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
51
+ handler: (c: FakeCall) => { stdout?: string; stderr?: string; exitCode?: number },
52
+ calls: FakeCall[] = [],
53
+ ) =>
54
+ Effect.runPromise(
55
+ Effect.provideService(
56
+ effect,
57
+ ChildProcessSpawner.ChildProcessSpawner,
58
+ fakeSpawner(handler, calls),
59
+ ),
60
+ );
61
+
62
+ describe('read — answers presence/absence, no swallowing', () => {
63
+ it('returns undefined (absence) on a successful read with no members', async () => {
64
+ const result = await run(read(props()), notBootstrapped());
65
+ assert.equal(result, undefined);
66
+ });
67
+
68
+ it('returns attrs when members are present', async () => {
69
+ const result = await run(read(props()), bootstrapped());
70
+ assert.deepEqual(result, { bootstrapped: true, node: props().node });
71
+ });
72
+
73
+ it('propagates a transport failure instead of reporting absence', async () => {
74
+ await assert.rejects(run(read(props()), etcdReadFails()));
75
+ });
76
+ });
77
+
78
+ describe('diff — trusts state, never touches the live cluster once bootstrapped', () => {
79
+ it('reports noop for output.bootstrapped=true WITHOUT any live call', async () => {
80
+ const calls: FakeCall[] = [];
81
+ const result = await run(
82
+ diff(props(), { bootstrapped: true, node: props().node }),
83
+ etcdReadFails(), // would reject if actually called
84
+ calls,
85
+ );
86
+ assert.deepEqual(result, { action: 'noop' });
87
+ assert.equal(calls.length, 0, 'diff must not call bao/talosctl once bootstrapped is true');
88
+ });
89
+
90
+ it('returns undefined (defer) when there is no prior output', async () => {
91
+ const result = await run(diff(props(), undefined), bootstrapped());
92
+ assert.equal(result, undefined);
93
+ });
94
+ });
95
+
96
+ describe('reconcile — once means once (acceptance test 4)', () => {
97
+ it('CREATE path (output undefined): bootstraps once when absent', async () => {
98
+ const calls: FakeCall[] = [];
99
+ const result = await run(reconcile(props(), undefined), notBootstrapped(), calls);
100
+ assert.deepEqual(result, { bootstrapped: true, node: props().node });
101
+ assert.equal(calls.filter((c) => c.args[0] === 'bootstrap').length, 1);
102
+ });
103
+
104
+ it('CREATE path: already bootstrapped (adopted) never spawns bootstrap', async () => {
105
+ const calls: FakeCall[] = [];
106
+ await run(reconcile(props(), undefined), bootstrapped(), calls);
107
+ assert.ok(calls.every((c) => c.args[0] !== 'bootstrap'));
108
+ });
109
+
110
+ it('a failing confirmation read against bootstrapped state never spawns bootstrap, and refuses', async () => {
111
+ const calls: FakeCall[] = [];
112
+ await assert.rejects(
113
+ run(reconcile(props(), { bootstrapped: true, node: props().node }), etcdReadFails(), calls),
114
+ (error: unknown) => (error as { _tag?: string } | null)?._tag === 'TalosReBootstrapRefused',
115
+ );
116
+ assert.ok(
117
+ calls.every((c) => c.args[0] !== 'bootstrap'),
118
+ 'never re-bootstrap on a failing read',
119
+ );
120
+ });
121
+
122
+ it('an empty (successful) confirmation read against bootstrapped state never spawns bootstrap, and refuses', async () => {
123
+ const calls: FakeCall[] = [];
124
+ await assert.rejects(
125
+ run(reconcile(props(), { bootstrapped: true, node: props().node }), notBootstrapped(), calls),
126
+ (error: unknown) => (error as { _tag?: string } | null)?._tag === 'TalosReBootstrapRefused',
127
+ );
128
+ assert.ok(
129
+ calls.every((c) => c.args[0] !== 'bootstrap'),
130
+ 'never re-bootstrap on an empty read',
131
+ );
132
+ });
133
+
134
+ it('a successful confirmation read against bootstrapped state confirms without spawning bootstrap', async () => {
135
+ const calls: FakeCall[] = [];
136
+ const result = await run(
137
+ reconcile(props(), { bootstrapped: true, node: props().node }),
138
+ bootstrapped(),
139
+ calls,
140
+ );
141
+ assert.deepEqual(result, { bootstrapped: true, node: props().node });
142
+ assert.ok(calls.every((c) => c.args[0] !== 'bootstrap'));
143
+ });
144
+ });
145
+
146
+ describe('reconcile — I4 fix: split-brain peer guard (acceptance-adjacent, LAND red team)', () => {
147
+ const propsWithPeers = () => ({
148
+ node: '198.51.100.10',
149
+ peers: ['198.51.100.11', '198.51.100.12'],
150
+ target: TARGET,
151
+ });
152
+
153
+ const nodeArg = (call: FakeCall) => {
154
+ const idx = call.args.indexOf('--nodes');
155
+ return idx === -1 ? undefined : call.args[idx + 1];
156
+ };
157
+
158
+ it('bootstraps once every peer proves genuinely empty', async () => {
159
+ const calls: FakeCall[] = [];
160
+ const handler = (call: FakeCall) => {
161
+ if (call.command === 'bao')
162
+ return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
163
+ if (call.args[0] === 'bootstrap') return {};
164
+ if (call.args[0] === 'get') {
165
+ const target = nodeArg(call);
166
+ const ranBootstrap = calls.some((c) => c.args[0] === 'bootstrap');
167
+ return target === propsWithPeers().node
168
+ ? { stdout: ranBootstrap ? '{"id":"member-1"}' : '[]' }
169
+ : { stdout: '[]' }; // both peers stay empty the whole time
170
+ }
171
+ throw new Error(`unexpected call ${JSON.stringify(call)}`);
172
+ };
173
+ const result = await run(reconcile(propsWithPeers(), undefined), handler, calls);
174
+ assert.deepEqual(result, { bootstrapped: true, node: propsWithPeers().node });
175
+ assert.equal(calls.filter((c) => c.args[0] === 'bootstrap').length, 1);
176
+ });
177
+
178
+ it('refuses, and never spawns bootstrap, when a peer already reports etcd members', async () => {
179
+ const calls: FakeCall[] = [];
180
+ const handler = (call: FakeCall) => {
181
+ if (call.command === 'bao')
182
+ return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
183
+ if (call.args[0] === 'get') {
184
+ return nodeArg(call) === '198.51.100.11'
185
+ ? { stdout: '{"id":"member-1"}' }
186
+ : { stdout: '[]' };
187
+ }
188
+ throw new Error(`unexpected call ${JSON.stringify(call)}`);
189
+ };
190
+ await assert.rejects(
191
+ run(reconcile(propsWithPeers(), undefined), handler, calls),
192
+ (error: unknown) => (error as { _tag?: string } | null)?._tag === 'TalosReBootstrapRefused',
193
+ );
194
+ assert.ok(
195
+ calls.every((c) => c.args[0] !== 'bootstrap'),
196
+ 'a peer that may already anchor a cluster must never see a second bootstrap',
197
+ );
198
+ });
199
+
200
+ it('refuses, and never spawns bootstrap, when a peer read fails', async () => {
201
+ const calls: FakeCall[] = [];
202
+ const handler = (call: FakeCall) => {
203
+ if (call.command === 'bao')
204
+ return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
205
+ if (call.args[0] === 'get') {
206
+ return nodeArg(call) === '198.51.100.11'
207
+ ? { exitCode: 1, stderr: 'dial tcp: connection refused' }
208
+ : { stdout: '[]' };
209
+ }
210
+ throw new Error(`unexpected call ${JSON.stringify(call)}`);
211
+ };
212
+ await assert.rejects(
213
+ run(reconcile(propsWithPeers(), undefined), handler, calls),
214
+ (error: unknown) => (error as { _tag?: string } | null)?._tag === 'TalosReBootstrapRefused',
215
+ );
216
+ assert.ok(calls.every((c) => c.args[0] !== 'bootstrap'));
217
+ });
218
+ });