@homeflare/alchemy 0.39.0 → 0.41.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 (279) hide show
  1. package/README.md +10 -8
  2. package/dist/caddy/index.js.map +2 -2
  3. package/dist/ceph/auth-entity.d.ts +34 -0
  4. package/dist/ceph/auth-entity.d.ts.map +1 -0
  5. package/dist/ceph/ceph-argv-config.d.ts +10 -0
  6. package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
  7. package/dist/ceph/ceph-argv.d.ts +19 -0
  8. package/dist/ceph/ceph-argv.d.ts.map +1 -0
  9. package/dist/ceph/ceph-auth-form.d.ts +54 -0
  10. package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
  11. package/dist/ceph/ceph-auth-parse.d.ts +56 -0
  12. package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
  13. package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
  14. package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
  15. package/dist/ceph/ceph-config-options.d.ts +23 -0
  16. package/dist/ceph/ceph-config-options.d.ts.map +1 -0
  17. package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
  18. package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
  19. package/dist/ceph/ceph-transport.d.ts +37 -0
  20. package/dist/ceph/ceph-transport.d.ts.map +1 -0
  21. package/dist/ceph/fake-ceph-dial.d.ts +35 -0
  22. package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
  23. package/dist/ceph/index.d.ts +13 -0
  24. package/dist/ceph/index.d.ts.map +1 -0
  25. package/dist/ceph/index.js +1610 -0
  26. package/dist/ceph/index.js.map +38 -0
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/launchd/fake-runner.d.ts.map +1 -1
  30. package/dist/launchd/index.d.ts +1 -1
  31. package/dist/launchd/index.d.ts.map +1 -1
  32. package/dist/launchd/index.js +7 -1
  33. package/dist/launchd/index.js.map +5 -5
  34. package/dist/launchd/local-runner.d.ts.map +1 -1
  35. package/dist/launchd/runner.d.ts +21 -0
  36. package/dist/launchd/runner.d.ts.map +1 -1
  37. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  38. package/dist/linux/directory-lifecycle.d.ts.map +1 -1
  39. package/dist/linux/fake-linux-host.d.ts.map +1 -1
  40. package/dist/linux/index.d.ts +1 -1
  41. package/dist/linux/index.d.ts.map +1 -1
  42. package/dist/linux/index.js +16 -4
  43. package/dist/linux/index.js.map +7 -7
  44. package/dist/linux/ssh-runner.d.ts.map +1 -1
  45. package/dist/linux/sudo-runner.d.ts.map +1 -1
  46. package/dist/opnsense/alias-form.d.ts +14 -1
  47. package/dist/opnsense/alias-form.d.ts.map +1 -1
  48. package/dist/opnsense/alias.d.ts +1 -1
  49. package/dist/opnsense/alias.d.ts.map +1 -1
  50. package/dist/opnsense/group-form.d.ts +8 -2
  51. package/dist/opnsense/group-form.d.ts.map +1 -1
  52. package/dist/opnsense/group.d.ts +1 -1
  53. package/dist/opnsense/group.d.ts.map +1 -1
  54. package/dist/opnsense/index.js +15 -7
  55. package/dist/opnsense/index.js.map +7 -7
  56. package/dist/opnsense/wire.d.ts +42 -12
  57. package/dist/opnsense/wire.d.ts.map +1 -1
  58. package/dist/proxmox/index.js +62 -39
  59. package/dist/proxmox/index.js.map +8 -7
  60. package/dist/proxmox/qemu-volume.d.ts +12 -2
  61. package/dist/proxmox/qemu-volume.d.ts.map +1 -1
  62. package/dist/proxmox/storage-form.d.ts +28 -3
  63. package/dist/proxmox/storage-form.d.ts.map +1 -1
  64. package/dist/proxmox/storage-plugin-options.d.ts +2 -0
  65. package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
  66. package/dist/proxmox/storage-wire.d.ts +28 -1
  67. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  68. package/dist/proxmox/storage.d.ts +6 -1
  69. package/dist/proxmox/storage.d.ts.map +1 -1
  70. package/dist/release/index.d.ts +1 -1
  71. package/dist/release/index.d.ts.map +1 -1
  72. package/dist/release/index.js.map +2 -2
  73. package/dist/talos/credentials-write.d.ts +46 -0
  74. package/dist/talos/credentials-write.d.ts.map +1 -0
  75. package/dist/talos/credentials.d.ts +51 -9
  76. package/dist/talos/credentials.d.ts.map +1 -1
  77. package/dist/talos/fake-process.d.ts +56 -0
  78. package/dist/talos/fake-process.d.ts.map +1 -0
  79. package/dist/talos/index.d.ts +19 -4
  80. package/dist/talos/index.d.ts.map +1 -1
  81. package/dist/talos/index.js +357 -173
  82. package/dist/talos/index.js.map +16 -10
  83. package/dist/talos/kubeconfig-attrs.d.ts +4 -0
  84. package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
  85. package/dist/talos/kubeconfig.d.ts +41 -23
  86. package/dist/talos/kubeconfig.d.ts.map +1 -1
  87. package/dist/talos/machine-config-fixtures.d.ts +60 -0
  88. package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
  89. package/dist/talos/machine-config-poll.d.ts +43 -0
  90. package/dist/talos/machine-config-poll.d.ts.map +1 -0
  91. package/dist/talos/machine-config-read.d.ts +49 -0
  92. package/dist/talos/machine-config-read.d.ts.map +1 -0
  93. package/dist/talos/resource.d.ts +18 -0
  94. package/dist/talos/resource.d.ts.map +1 -1
  95. package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
  96. package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
  97. package/dist/talos/talos-bootstrap.d.ts +87 -0
  98. package/dist/talos/talos-bootstrap.d.ts.map +1 -1
  99. package/dist/talos/talos-cluster-health.d.ts +43 -1
  100. package/dist/talos/talos-cluster-health.d.ts.map +1 -1
  101. package/dist/talos/talos-errors.d.ts +68 -0
  102. package/dist/talos/talos-errors.d.ts.map +1 -0
  103. package/dist/talos/talos-machine-config.d.ts +51 -15
  104. package/dist/talos/talos-machine-config.d.ts.map +1 -1
  105. package/dist/talos/talosctl.d.ts +7 -0
  106. package/dist/talos/talosctl.d.ts.map +1 -1
  107. package/dist/talos/values.d.ts +23 -3
  108. package/dist/talos/values.d.ts.map +1 -1
  109. package/dist/unifi/acl-rule-drift.d.ts +22 -0
  110. package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
  111. package/dist/unifi/acl-rule-form.d.ts +90 -0
  112. package/dist/unifi/acl-rule-form.d.ts.map +1 -0
  113. package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
  114. package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
  115. package/dist/unifi/acl-rule-ordering.d.ts +54 -0
  116. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
  117. package/dist/unifi/acl-rule.d.ts +49 -0
  118. package/dist/unifi/acl-rule.d.ts.map +1 -0
  119. package/dist/unifi/dns-policy-drift.d.ts +21 -0
  120. package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
  121. package/dist/unifi/dns-policy-form.d.ts +97 -0
  122. package/dist/unifi/dns-policy-form.d.ts.map +1 -0
  123. package/dist/unifi/dns-policy.d.ts +55 -0
  124. package/dist/unifi/dns-policy.d.ts.map +1 -0
  125. package/dist/unifi/firewall-policy-drift.d.ts +26 -0
  126. package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
  127. package/dist/unifi/firewall-policy-form.d.ts +102 -0
  128. package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
  129. package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
  130. package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
  131. package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
  132. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
  133. package/dist/unifi/firewall-policy.d.ts +57 -0
  134. package/dist/unifi/firewall-policy.d.ts.map +1 -0
  135. package/dist/unifi/index.d.ts +7 -0
  136. package/dist/unifi/index.d.ts.map +1 -1
  137. package/dist/unifi/index.js +588 -3
  138. package/dist/unifi/index.js.map +24 -4
  139. package/dist/unifi/paginate.d.ts +0 -12
  140. package/dist/unifi/paginate.d.ts.map +1 -1
  141. package/dist/unifi/providers.d.ts +1 -1
  142. package/dist/unifi/providers.d.ts.map +1 -1
  143. package/dist/unifi/scan-source.d.ts +11 -0
  144. package/dist/unifi/scan-source.d.ts.map +1 -0
  145. package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
  146. package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
  147. package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
  148. package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
  149. package/dist/unifi/traffic-matching-list.d.ts +51 -0
  150. package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
  151. package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
  152. package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
  153. package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
  154. package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
  155. package/dist/unifi/wifi-broadcast.d.ts +73 -0
  156. package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
  157. package/docs/distilled-interim.md +7 -0
  158. package/docs/launchd.md +5 -3
  159. package/docs/provider-standard.md +5 -1
  160. package/docs/talos-argocd-dogfood.md +160 -0
  161. package/docs/unifi-acl-rule.md +122 -0
  162. package/docs/unifi-dns-policy.md +75 -0
  163. package/docs/unifi-firewall-policy.md +109 -0
  164. package/docs/unifi-traffic-matching-list.md +82 -0
  165. package/docs/unifi-wifi.md +120 -0
  166. package/docs/unifi.md +10 -5
  167. package/package.json +7 -3
  168. package/src/ceph/auth-entity.ts +74 -0
  169. package/src/ceph/ceph-argv-config.ts +78 -0
  170. package/src/ceph/ceph-argv.test.ts +226 -0
  171. package/src/ceph/ceph-argv.ts +189 -0
  172. package/src/ceph/ceph-auth-form.test.ts +103 -0
  173. package/src/ceph/ceph-auth-form.ts +107 -0
  174. package/src/ceph/ceph-auth-parse.test.ts +155 -0
  175. package/src/ceph/ceph-auth-parse.ts +121 -0
  176. package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
  177. package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
  178. package/src/ceph/ceph-auth-reconcile.ts +119 -0
  179. package/src/ceph/ceph-config-options.test.ts +44 -0
  180. package/src/ceph/ceph-config-options.ts +36 -0
  181. package/src/ceph/ceph-key-leak-assert.ts +35 -0
  182. package/src/ceph/ceph-no-output-logging.test.ts +52 -0
  183. package/src/ceph/ceph-transport.test.ts +125 -0
  184. package/src/ceph/ceph-transport.ts +117 -0
  185. package/src/ceph/fake-ceph-dial.ts +55 -0
  186. package/src/ceph/index.ts +12 -0
  187. package/src/index.ts +1 -0
  188. package/src/launchd/fake-runner.ts +2 -0
  189. package/src/launchd/index.ts +8 -1
  190. package/src/launchd/local-runner.ts +16 -1
  191. package/src/launchd/runner.ts +22 -0
  192. package/src/launchd/sudo-runner.ts +3 -0
  193. package/src/linux/directory-lifecycle.ts +18 -3
  194. package/src/linux/directory-sudo.test.ts +59 -0
  195. package/src/linux/directory.test.ts +34 -8
  196. package/src/linux/fake-linux-host.ts +2 -0
  197. package/src/linux/index.ts +1 -0
  198. package/src/linux/ssh-runner.ts +4 -0
  199. package/src/linux/sudo-runner.ts +3 -0
  200. package/src/opnsense/alias-form.test.ts +24 -12
  201. package/src/opnsense/alias-form.ts +20 -7
  202. package/src/opnsense/alias.test.ts +13 -6
  203. package/src/opnsense/alias.ts +16 -10
  204. package/src/opnsense/group-form.test.ts +12 -8
  205. package/src/opnsense/group-form.ts +13 -4
  206. package/src/opnsense/group.test.ts +18 -7
  207. package/src/opnsense/group.ts +10 -3
  208. package/src/opnsense/wire.ts +57 -12
  209. package/src/proxmox/qemu-volume.test.ts +153 -4
  210. package/src/proxmox/qemu-volume.ts +71 -14
  211. package/src/proxmox/storage-form.ts +48 -8
  212. package/src/proxmox/storage-plugin-options.ts +45 -0
  213. package/src/proxmox/storage-update.test.ts +183 -0
  214. package/src/proxmox/storage-wire.ts +54 -7
  215. package/src/proxmox/storage.ts +18 -6
  216. package/src/release/index.ts +1 -0
  217. package/src/talos/credentials-write.test.ts +81 -0
  218. package/src/talos/credentials-write.ts +95 -0
  219. package/src/talos/credentials.test.ts +157 -0
  220. package/src/talos/credentials.ts +160 -90
  221. package/src/talos/fake-process.ts +94 -0
  222. package/src/talos/index.ts +39 -4
  223. package/src/talos/kubeconfig-attrs.ts +55 -0
  224. package/src/talos/kubeconfig.test.ts +256 -0
  225. package/src/talos/kubeconfig.ts +170 -90
  226. package/src/talos/machine-config-fixtures.ts +86 -0
  227. package/src/talos/machine-config-poll.test.ts +57 -0
  228. package/src/talos/machine-config-poll.ts +82 -0
  229. package/src/talos/machine-config-read.ts +118 -0
  230. package/src/talos/resource.ts +19 -0
  231. package/src/talos/secrets-gate.test.ts +63 -0
  232. package/src/talos/talos-bootstrap-etcd.ts +71 -0
  233. package/src/talos/talos-bootstrap.test.ts +218 -0
  234. package/src/talos/talos-bootstrap.ts +151 -45
  235. package/src/talos/talos-cluster-health.test.ts +143 -0
  236. package/src/talos/talos-cluster-health.ts +91 -28
  237. package/src/talos/talos-errors.ts +85 -0
  238. package/src/talos/talos-machine-config.test.ts +189 -0
  239. package/src/talos/talos-machine-config.ts +102 -88
  240. package/src/talos/talosctl.ts +8 -0
  241. package/src/talos/values.test.ts +34 -6
  242. package/src/talos/values.ts +41 -7
  243. package/src/unifi/acl-rule-drift.ts +57 -0
  244. package/src/unifi/acl-rule-form.ts +135 -0
  245. package/src/unifi/acl-rule-ordering-form.ts +79 -0
  246. package/src/unifi/acl-rule-ordering.test.ts +141 -0
  247. package/src/unifi/acl-rule-ordering.ts +70 -0
  248. package/src/unifi/acl-rule.test.ts +183 -0
  249. package/src/unifi/acl-rule.ts +61 -0
  250. package/src/unifi/discriminated-filter-decode.test.ts +134 -0
  251. package/src/unifi/dns-policy-drift.ts +48 -0
  252. package/src/unifi/dns-policy-form.ts +142 -0
  253. package/src/unifi/dns-policy.test.ts +154 -0
  254. package/src/unifi/dns-policy.ts +67 -0
  255. package/src/unifi/firewall-policy-drift.test.ts +151 -0
  256. package/src/unifi/firewall-policy-drift.ts +55 -0
  257. package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
  258. package/src/unifi/firewall-policy-form.ts +146 -0
  259. package/src/unifi/firewall-policy-ordering-form.ts +111 -0
  260. package/src/unifi/firewall-policy-ordering.test.ts +185 -0
  261. package/src/unifi/firewall-policy-ordering.ts +87 -0
  262. package/src/unifi/firewall-policy.test.ts +183 -0
  263. package/src/unifi/firewall-policy.ts +73 -0
  264. package/src/unifi/index.ts +63 -0
  265. package/src/unifi/paginate.test.ts +24 -0
  266. package/src/unifi/paginate.ts +15 -3
  267. package/src/unifi/providers.ts +19 -1
  268. package/src/unifi/scan-source.ts +64 -0
  269. package/src/unifi/traffic-matching-list-drift.ts +40 -0
  270. package/src/unifi/traffic-matching-list-form.ts +77 -0
  271. package/src/unifi/traffic-matching-list.test.ts +202 -0
  272. package/src/unifi/traffic-matching-list.ts +78 -0
  273. package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
  274. package/src/unifi/wifi-broadcast-drift.ts +69 -0
  275. package/src/unifi/wifi-broadcast-form.ts +211 -0
  276. package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
  277. package/src/unifi/wifi-broadcast.test.ts +155 -0
  278. package/src/unifi/wifi-broadcast.ts +90 -0
  279. package/src/unifi/write-op-reference.test.ts +8 -54
@@ -1,20 +1,38 @@
1
1
  /**
2
- * The Talos client credential a reconcile runs with: minted from OpenBao, written to a temp file.
2
+ * Talos client material minted from OpenBao, written to a session-temp file whose lifetime the
3
+ * CALLER owns — never this module.
3
4
  *
4
- * ⛔ TALOSCONFIG AND secrets.yaml HOLD CLIENT CERTIFICATES AND CLUSTER CA PRIVATE KEYS. Alchemy
5
- * persists resource attributes WITHOUT encryption — StateEncoding.ts writes `Redacted` as
6
- * `{"@redacted": <plaintext>}` — and this estate's state store is the `alchemy` Postgres that
7
- * a nightly job dumps. Neither file may be a prop, an attribute, or a log line.
5
+ * ⛔ TALOSCONFIG, MACHINE CONFIG AND secrets.yaml CAN ALL HOLD CLIENT CERTIFICATES AND CLUSTER CA
6
+ * PRIVATE KEYS. Alchemy persists resource attributes WITHOUT encryption — `StateEncoding.ts`
7
+ * tags a `Redacted` value as `{ __redacted__: <plaintext> }` (`REDACTED_MARKER`), it does not
8
+ * encrypt it — and this estate's state store is the shared Cloudflare HTTP state store, not a
9
+ * database anyone forgets is world-legible. None of these three may ever be a prop, an
10
+ * attribute or a log line; see docs/plans/2026-09-26-talos-secrets-flow.md for why the OLD
11
+ * header here (Postgres, `{"@redacted": …}`) was wrong on both counts — the RULE survives the
12
+ * correction either way.
8
13
  *
9
- * ★ MODELED ON <estate>/proxmox/src/credentials.ts: mint at call time through `ChildProcessSpawner`,
10
- * use the material to build a client, drop it when the scope ends. The difference is Talos wants a
11
- * FILE (`--talosconfig` / `TALOSCONFIG`), so mint writes a temp path and registers cleanup.
14
+ * ★ MODELED ON proxmox/credentials.ts + proxmox/mint.ts: mint at call time, use the material, drop
15
+ * it when the scope ends. The difference is Talos wants a FILE (`--talosconfig` / `-f`), so
16
+ * minting here writes a temp path and registers its own delete finalizer instead of just
17
+ * building a header.
18
+ *
19
+ * ⛔ C1 FIX (2026-09-26, K-A3) — THE SHIPPED `mintTalosconfig` DELETED ITS OWN FILE BEFORE ANY
20
+ * CALLER COULD USE IT. It wrapped its whole body in `Effect.scoped(...)` and registered the
21
+ * delete finalizer inside that same scope, so the scope — and the file — closed the instant
22
+ * `mintTalosconfig` RETURNED, before the caller ever got to pass the path to `talosctl`. Fixed
23
+ * by `Effect.acquireRelease`: it still deletes on every exit path, but contributes `Scope.Scope`
24
+ * to this module's own return type instead of discharging it, so the file survives until
25
+ * whichever `Effect.scoped(...)` the CALLER wraps around its own use of the path (every
26
+ * talos-*.ts resource file wraps its `read`/`reconcile` bodies for exactly this reason now).
12
27
  *
13
28
  * ⚠️ REASONED FROM THE PUBLISHED TALOS WORKFLOW, NOT MEASURED ON THIS ESTATE. There is no Talos
14
- * cluster here yet; paths below follow the same `mount/data/…` shape other infra mounts use.
29
+ * cluster here yet; KV paths below follow the `mount/<key>` shape O1 (the secrets-flow doc)
30
+ * assigns, and `bao kv get` inserts the KV-v2 `data/` API segment itself — a key already
31
+ * prefixed `data/` reads `<mount>/data/data/<key>`, which the OLD default did.
15
32
  */
16
33
  import { closeSync, openSync, writeFileSync } from 'node:fs';
17
34
  import * as Effect from 'effect/Effect';
35
+ import type * as Scope from 'effect/Scope';
18
36
  import * as Stream from 'effect/Stream';
19
37
  import * as ChildProcess from 'effect/unstable/process/ChildProcess';
20
38
  import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
@@ -26,10 +44,10 @@ export type TalosTarget = {
26
44
  /** Logical cluster name — safe to persist and log. */
27
45
  readonly cluster: string;
28
46
  /**
29
- * Path under the mount for the talosconfig YAML.
30
- *
31
- * ⚠️ DEFAULT IS REASONED. The first real deploy will confirm or correct the path; a wrong path
32
- * fails closed with a bao error rather than writing an empty talosconfig.
47
+ * Path under the mount for the talosconfig YAML. Default `'talosconfig'` — FIXED 2026-09-26;
48
+ * the shipped default was `'data/talosconfig'`, which `bao kv get` turned into
49
+ * `<mount>/data/data/talosconfig` (see the ⚠️ above). It failed closed rather than reading an
50
+ * empty talosconfig, but it would have blocked the very first deploy.
33
51
  */
34
52
  readonly talosconfigKey?: string;
35
53
  };
@@ -39,98 +57,150 @@ export type TalosCredential = {
39
57
  readonly talosconfigPath: string;
40
58
  };
41
59
 
42
- const defaultKey = (target: TalosTarget) => target.talosconfigKey ?? 'data/talosconfig';
60
+ const DEFAULT_TALOSCONFIG_KEY = 'talosconfig';
43
61
 
44
62
  /**
45
- * Mint one talosconfig file for `target`.
63
+ * Default OpenBao KV path (under `target.mount`) for the admin kubeconfig `Talos.Kubeconfig`
64
+ * writes once at cluster bring-up. See kubeconfig.ts's own header (K-talos-first-boot, 2026-09-26)
65
+ * and docs/plans/2026-09-26-talos-secrets-flow.md's O1 key list.
66
+ */
67
+ export const DEFAULT_KUBECONFIG_KEY = 'kubeconfig';
68
+
69
+ /**
70
+ * Read one OpenBao KV-v2 value and return the first of `fields` that is a non-empty string.
46
71
  *
47
- * ★ SHELLS OUT TO `bao` RATHER THAN SPEAKING THE HTTP API — same reasoning as proxmox credentials.
48
- * The estate's BAO_ADDR / BAO_NAMESPACE / approle contract already lives in that binary.
72
+ * ★ THE ONE PLACE THIS PACKAGE SHELLS TO `bao` — talosconfig, per-node machine config and any
73
+ * future KV-backed Talos material (secrets.yaml, ceph entity keys) all read through this,
74
+ * so there is exactly one spot that must never echo a KV value on failure.
75
+ * ⛔ stderr ONLY ON FAILURE. stdout may hold the very secret this call exists to fetch.
49
76
  */
50
- export const mintTalosconfig = (target: TalosTarget) =>
51
- Effect.scoped(
52
- Effect.gen(function* () {
53
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
54
- const key = defaultKey(target);
55
- const result = yield* ChildProcess.make(
56
- 'bao',
57
- ['kv', 'get', '-format=json', `${target.mount}/${key}`],
58
- { detached: false, extendEnv: true, stderr: 'pipe', stdin: 'ignore', stdout: 'pipe' },
59
- ).pipe(
60
- spawner.spawn,
61
- Effect.flatMap((child) =>
62
- Effect.all(
63
- {
64
- exitCode: child.exitCode,
65
- stderr: child.stderr.pipe(
66
- Stream.decodeText,
67
- Stream.mkString,
68
- Effect.map((text) => text.trim()),
69
- ),
70
- stdout: child.stdout.pipe(
71
- Stream.decodeText,
72
- Stream.mkString,
73
- Effect.map((text) => text.trim()),
74
- ),
75
- },
76
- { concurrency: 'unbounded' },
77
- ),
77
+ export const readKvValue = (
78
+ mount: string,
79
+ key: string,
80
+ fields: readonly string[],
81
+ ): Effect.Effect<string, Error, ChildProcessSpawner.ChildProcessSpawner> =>
82
+ Effect.gen(function* () {
83
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
84
+ const result = yield* ChildProcess.make(
85
+ 'bao',
86
+ ['kv', 'get', '-format=json', `${mount}/${key}`],
87
+ { detached: false, extendEnv: true, stderr: 'pipe', stdin: 'ignore', stdout: 'pipe' },
88
+ ).pipe(
89
+ spawner.spawn,
90
+ Effect.flatMap((child) =>
91
+ Effect.all(
92
+ {
93
+ exitCode: child.exitCode,
94
+ stderr: child.stderr.pipe(
95
+ Stream.decodeText,
96
+ Stream.mkString,
97
+ Effect.map((text) => text.trim()),
98
+ ),
99
+ stdout: child.stdout.pipe(
100
+ Stream.decodeText,
101
+ Stream.mkString,
102
+ Effect.map((text) => text.trim()),
103
+ ),
104
+ },
105
+ { concurrency: 'unbounded' },
78
106
  ),
79
- Effect.scoped,
80
- );
107
+ ),
108
+ Effect.scoped,
109
+ );
81
110
 
82
- if (result.exitCode !== 0) {
83
- // ⛔ stderr ONLY — stdout may hold talosconfig YAML with client credentials.
84
- return yield* Effect.fail(
85
- new Error(
86
- `bao kv get ${target.mount}/${key} exited ${String(result.exitCode)}: ${result.stderr}`,
87
- ),
88
- );
89
- }
111
+ if (result.exitCode !== 0) {
112
+ return yield* Effect.fail(
113
+ new Error(`bao kv get ${mount}/${key} exited ${String(result.exitCode)}: ${result.stderr}`),
114
+ );
115
+ }
90
116
 
91
- const parsed = JSON.parse(result.stdout) as { data?: { data?: Record<string, unknown> } };
92
- const data = parsed.data?.data;
93
- const raw = data?.['talosconfig'] ?? data?.['config'];
94
- if (typeof raw !== 'string' || raw.trim() === '') {
95
- return yield* Effect.fail(
96
- new Error(
97
- `${target.mount}/${key} returned no talosconfig field. Expected a KV key named 'talosconfig' or 'config' — never store the value in Alchemy props.`,
98
- ),
99
- );
100
- }
117
+ const parsed = JSON.parse(result.stdout) as { data?: { data?: Record<string, unknown> } };
118
+ const data = parsed.data?.data;
119
+ for (const field of fields) {
120
+ const raw = data?.[field];
121
+ if (typeof raw === 'string' && raw.trim() !== '') return raw;
122
+ }
123
+ return yield* Effect.fail(
124
+ new Error(
125
+ `${mount}/${key} returned no ${fields.join('/')} field. Never store the value itself in ` +
126
+ 'an Alchemy prop or attribute — fix the KV entry, not the caller.',
127
+ ),
128
+ );
129
+ });
101
130
 
102
- const talosconfigPath = `${Bun.env['TMPDIR'] ?? '/tmp'}/hf-talos-${target.cluster}-${Bun.randomUUIDv7()}.yaml`;
103
- /**
104
- * ⛔ 0600, CREATED EXCLUSIVELY, AND `Bun.write` CANNOT DO EITHER. This file holds the
105
- * cluster's admin client certificate — everything talosctl needs to reconfigure or wipe a
106
- * node. `Bun.write` takes no mode, so it lands at the process umask, which on this estate
107
- * is 0644: world-readable, in a world-readable directory, for as long as the resource
108
- * runs. The name is unguessable, and "unguessable" is not a permission.
109
- * ⚠️ `wx` IS THE OTHER HALF. Opening with O_EXCL means this cannot be made to write through
110
- * a path an attacker pre-created — the classic /tmp symlink race — and with a UUIDv7 name
111
- * a collision is a genuine error rather than something to paper over.
112
- * ⚠️ THE FINALIZER BELOW REMOVES IT ON EVERY EXIT PATH, but a hard kill (SIGKILL, power
113
- * loss) leaves it behind. 0600 is what makes that survivable rather than a disclosure.
114
- */
115
- yield* Effect.try({
131
+ /**
132
+ * Write `raw` to a session-temp, 0600, exclusively-created file whose lifetime is the CALLER's
133
+ * `Effect.scoped`, not this call's. See the ⛔ C1 FIX note at the top of this file.
134
+ *
135
+ * ⛔ 0600, CREATED EXCLUSIVELY, AND `Bun.write` CANNOT DO EITHER. This file can hold a cluster
136
+ * admin client certificate or a machine config's bootstrap token. `Bun.write` takes no mode, so
137
+ * it lands at the process umask — world-readable on this estate — for as long as the resource
138
+ * runs. The name is unguessable, and "unguessable" is not a permission.
139
+ * ⚠️ `wx` IS THE OTHER HALF. `O_EXCL` means this cannot be made to write through a path an
140
+ * attacker pre-created (the classic /tmp symlink race), and with a UUIDv7 name a collision is a
141
+ * genuine error rather than something to paper over.
142
+ * ⚠️ A HARD KILL (SIGKILL, power loss) skips the finalizer and leaves the file behind. 0600 is
143
+ * what makes that survivable rather than a disclosure.
144
+ */
145
+ export const mintKvTempFile = (
146
+ raw: string,
147
+ label: string,
148
+ ): Effect.Effect<{ readonly path: string }, Error, Scope.Scope> =>
149
+ Effect.gen(function* () {
150
+ const path = `${Bun.env['TMPDIR'] ?? '/tmp'}/hf-talos-${label}-${Bun.randomUUIDv7()}.yaml`;
151
+ yield* Effect.acquireRelease(
152
+ Effect.try({
116
153
  try: () => {
117
- const fd = openSync(talosconfigPath, 'wx', 0o600);
154
+ const fd = openSync(path, 'wx', 0o600);
118
155
  try {
119
156
  writeFileSync(fd, raw);
120
157
  } finally {
121
158
  closeSync(fd);
122
159
  }
123
160
  },
124
- catch: (cause) => new Error(`writing temp talosconfig: ${String(cause)}`),
125
- });
161
+ catch: (cause) => new Error(`writing ${label} temp file: ${String(cause)}`),
162
+ }),
163
+ () =>
164
+ Effect.tryPromise({ try: () => Bun.file(path).delete(), catch: () => undefined }).pipe(
165
+ Effect.orElseSucceed(() => undefined),
166
+ ),
167
+ );
168
+ return { path };
169
+ });
126
170
 
127
- yield* Effect.addFinalizer(() =>
128
- Effect.tryPromise({
129
- try: () => Bun.file(talosconfigPath).delete(),
130
- catch: () => undefined,
131
- }).pipe(Effect.orElseSucceed(() => undefined)),
132
- );
171
+ /**
172
+ * Mint one talosconfig file for `target`.
173
+ *
174
+ * ⛔ CALLERS MUST WRAP THEIR WHOLE USE OF `credential.talosconfigPath` — not just this call — in
175
+ * `Effect.scoped(...)`. This function's own `Scope.Scope` requirement is what makes that the
176
+ * caller's job instead of a lie this function tells itself; see the header above.
177
+ */
178
+ export const mintTalosconfig = (
179
+ target: TalosTarget,
180
+ ): Effect.Effect<TalosCredential, Error, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope> =>
181
+ readKvValue(target.mount, target.talosconfigKey ?? DEFAULT_TALOSCONFIG_KEY, [
182
+ 'talosconfig',
183
+ 'config',
184
+ ]).pipe(
185
+ Effect.flatMap((raw) => mintKvTempFile(raw, `${target.cluster}-talosconfig`)),
186
+ Effect.map(({ path }): TalosCredential => ({ talosconfigPath: path })),
187
+ );
133
188
 
134
- return { talosconfigPath } satisfies TalosCredential;
135
- }),
189
+ /**
190
+ * Mint one temp kubeconfig file from the vault copy `Talos.Kubeconfig` writes at bring-up
191
+ * (K-talos-first-boot, 2026-09-26) — the consumer-side counterpart to {@link mintTalosconfig},
192
+ * named as the future seam for `Kubernetes.ClusterAdapter` in
193
+ * docs/plans/2026-09-26-talos-secrets-flow.md ("consumers mint it the way the talosconfig is
194
+ * minted"). Not yet wired to that adapter — this only makes the read+materialize step exist.
195
+ */
196
+ export const mintKubeconfig = (
197
+ target: TalosTarget,
198
+ key = DEFAULT_KUBECONFIG_KEY,
199
+ ): Effect.Effect<
200
+ { readonly path: string },
201
+ Error,
202
+ ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
203
+ > =>
204
+ readKvValue(target.mount, key, ['kubeconfig', 'config']).pipe(
205
+ Effect.flatMap((raw) => mintKvTempFile(raw, `${target.cluster}-kubeconfig`)),
136
206
  );
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A fake `ChildProcessSpawner` for offline Talos tests — no real `bao` or `talosctl` process ever
3
+ * starts. Shared by credentials.test.ts and talos-machine-config.test.ts (2026-09-26, K-A3);
4
+ * this package had NO process-level test fake before this file — every Talos resource file spawns
5
+ * a real binary in production and none of that was previously exercised offline.
6
+ *
7
+ * ★ A FULL FAKE SERVICE, NOT A REAL SPAWN WITH A FIXTURE EXECUTABLE ON `PATH`. The alternative —
8
+ * a tiny fake `bao`/`talosctl` script and a mutated `process.env.PATH` — spawns a REAL OS
9
+ * process and mutates process-global state for the test's duration, which is exactly the kind
10
+ * of cross-test interference the house rules ask tests to avoid. `ChildProcessSpawner.make`
11
+ * takes just a `spawn` function and derives everything else, so a fake `spawn` is the whole cost.
12
+ */
13
+ import * as Effect from 'effect/Effect';
14
+ import * as Sink from 'effect/Sink';
15
+ import * as Stream from 'effect/Stream';
16
+ import type { Command } from 'effect/unstable/process/ChildProcess';
17
+ import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
18
+
19
+ export type FakeResult = {
20
+ readonly stdout?: string;
21
+ readonly stderr?: string;
22
+ readonly exitCode?: number;
23
+ };
24
+ export type FakeCall = {
25
+ readonly command: string;
26
+ readonly args: readonly string[];
27
+ /**
28
+ * Decoded stdin text, present only when the real caller piped a `Stream` in (K-talos-first-boot,
29
+ * `credentials-write.ts`'s `writeKvValue`) — the whole reason this fake exists is to prove a
30
+ * value went in via stdin and never via argv, so a test needs to see it.
31
+ */
32
+ readonly stdin?: string;
33
+ };
34
+ export type FakeHandler = (call: FakeCall) => FakeResult;
35
+
36
+ const encode = (text: string) => new TextEncoder().encode(text);
37
+
38
+ const fakeHandle = (result: FakeResult): ChildProcessSpawner.ChildProcessHandle =>
39
+ ChildProcessSpawner.makeHandle({
40
+ all: Stream.fromIterable([encode((result.stdout ?? '') + (result.stderr ?? ''))]),
41
+ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode ?? 0)),
42
+ getInputFd: () => Sink.drain,
43
+ getOutputFd: () => Stream.empty,
44
+ isRunning: Effect.succeed(false),
45
+ kill: () => Effect.void,
46
+ pid: ChildProcessSpawner.ProcessId(1),
47
+ stderr: Stream.fromIterable([encode(result.stderr ?? '')]),
48
+ stdin: Sink.drain,
49
+ stdout: Stream.fromIterable([encode(result.stdout ?? '')]),
50
+ unref: Effect.succeed(Effect.void),
51
+ });
52
+
53
+ /**
54
+ * Decode a `StandardCommand`'s `options.stdin` into text, or `undefined` when there was none to
55
+ * decode. Only `"pipe" | "inherit" | "ignore" | "overlapped"` (no bytes) and a raw `Stream` (the
56
+ * only two shapes anything in this package ever passes — `credentials-write.ts`'s `writeKvValue`
57
+ * uses `Stream.fromIterable`, matching `alchemy`'s own `Cloudflare/Workers/ViteChild.ts` usage) are
58
+ * handled; a `StdinConfig` wrapper is unwrapped to its own `.stream` field first.
59
+ */
60
+ const stdinText = (stdin: unknown) =>
61
+ Effect.gen(function* () {
62
+ if (stdin === undefined || typeof stdin === 'string') return undefined;
63
+ const stream =
64
+ typeof stdin === 'object' && stdin !== null && 'stream' in stdin
65
+ ? (stdin as { stream: unknown }).stream
66
+ : stdin;
67
+ if (stream === undefined || typeof stream === 'string') return undefined;
68
+ const chunks = yield* Stream.runCollect(stream as Stream.Stream<Uint8Array>);
69
+ return chunks.map((chunk) => new TextDecoder().decode(chunk)).join('');
70
+ }).pipe(Effect.orDie);
71
+
72
+ /**
73
+ * Build a fake `ChildProcessSpawner.Service` from `handler`, which sees every command's
74
+ * executable + argv (+ decoded stdin, when piped) and returns canned stdout/stderr/exitCode.
75
+ * `credentials.ts` and `talosctl.ts` only ever build `StandardCommand`s (never a piped command),
76
+ * so that is the only shape this fake accepts — a `PipedCommand` is a test-authoring mistake, not
77
+ * something to fake.
78
+ */
79
+ export const fakeSpawner = (handler: FakeHandler, calls: FakeCall[] = []) =>
80
+ ChildProcessSpawner.make((command: Command) =>
81
+ Effect.gen(function* () {
82
+ if (command._tag !== 'StandardCommand') {
83
+ return yield* Effect.die(new Error('fake-process: only StandardCommand is supported'));
84
+ }
85
+ const stdin = yield* stdinText(command.options.stdin);
86
+ const call: FakeCall = {
87
+ args: command.args,
88
+ command: command.command,
89
+ ...(stdin === undefined ? {} : { stdin }),
90
+ };
91
+ calls.push(call);
92
+ return fakeHandle(handler(call));
93
+ }),
94
+ );
@@ -5,10 +5,45 @@
5
5
  * These are the symbols a real stack consumes; the rest of the files are internals a
6
6
  * provider needs but a consumer should not depend on. An `export *` here would publish
7
7
  * every helper as API and make the next refactor a breaking change.
8
+ * ★ `Talos.MachineConfig`'S RESOURCE CLASS JOINED ITS PROVIDER 2026-09-26 (K-A3) — until now this
9
+ * barrel exported only `Talos*Provider`, which builds the provider but gives a consuming stack
10
+ * nothing to `new` or call to actually DECLARE a row (the same gap proxmox/index.ts's own header
11
+ * calls out for `ProxmoxVm`). MachineConfig is safe to declare today: it fails closed on a digest
12
+ * mismatch before touching a node, and its cold-start read never silently adopts (PR 307 fix-first
13
+ * #1, machine-config-read.ts's own header).
14
+ * ★ BOOTSTRAP, CLUSTERHEALTH AND KUBECONFIG JOIN THEM (K-talos-first-boot, 2026-09-26) — PR 307's
15
+ * red team (fix-first #3) held these back because `Talos.Bootstrap` could re-run `talosctl
16
+ * bootstrap` against an already-bootstrapped cluster and `Talos.Kubeconfig` wrote a cluster-admin
17
+ * kubeconfig to un-vaulted host disk. Both are fixed now: bootstrap's "once means once" invariant
18
+ * (talos-bootstrap.ts's own header) and kubeconfig landing in OpenBao, written once at bring-up
19
+ * (kubeconfig.ts's own header) — plus `Talos.ClusterHealth`'s error handling no longer conflates a
20
+ * vault/transport failure with "cluster not healthy yet" (talos-cluster-health.ts's own header).
8
21
  * ★ Anything unlisted is still reachable by path if you genuinely need it — that is a
9
22
  * deliberate, visible act rather than an accident of barrelling.
10
23
  */
11
- export { TalosKubeconfigProvider } from './kubeconfig.ts';
12
- export { TalosBootstrapProvider } from './talos-bootstrap.ts';
13
- export { TalosClusterHealthProvider } from './talos-cluster-health.ts';
14
- export { TalosMachineConfigProvider } from './talos-machine-config.ts';
24
+ export type { TalosCredential, TalosTarget } from './credentials.ts';
25
+ export {
26
+ type KubeconfigAttributes,
27
+ type KubeconfigProps,
28
+ TalosKubeconfig,
29
+ TalosKubeconfigProvider,
30
+ } from './kubeconfig.ts';
31
+ export type { ApplyMode, WithTarget } from './resource.ts';
32
+ export {
33
+ type BootstrapAttributes,
34
+ type BootstrapProps,
35
+ TalosBootstrap,
36
+ TalosBootstrapProvider,
37
+ } from './talos-bootstrap.ts';
38
+ export {
39
+ type ClusterHealthAttributes,
40
+ type ClusterHealthProps,
41
+ TalosClusterHealth,
42
+ TalosClusterHealthProvider,
43
+ } from './talos-cluster-health.ts';
44
+ export {
45
+ type MachineConfigAttributes,
46
+ type MachineConfigProps,
47
+ TalosMachineConfig,
48
+ TalosMachineConfigProvider,
49
+ } from './talos-machine-config.ts';
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `Talos.Kubeconfig`'s attribute builder and its `Kubernetes.Connection` — split out of
3
+ * kubeconfig.ts (LAND, 2026-09-26, C1/I2 fixes) once fixing those findings pushed the file over the
4
+ * 250-line cap, the same reason machine-config-read.ts/machine-config-poll.ts/talos-errors.ts were
5
+ * split out of talos-machine-config.ts before it. Only `import type` comes back from kubeconfig.ts,
6
+ * so there is no runtime cycle between the two files.
7
+ */
8
+ import type { Connection } from 'alchemy/Kubernetes/Connection';
9
+ import type { KubeconfigAttributes, KubeconfigProps } from './kubeconfig.ts';
10
+ import { kubeconfigMetadata, sha256 } from './values.ts';
11
+
12
+ const generation = (meta: {
13
+ endpoint: string;
14
+ caFingerprint: string;
15
+ clientFingerprint: string;
16
+ context: string;
17
+ }) => sha256(`${meta.context}\n${meta.endpoint}\n${meta.caFingerprint}\n${meta.clientFingerprint}`);
18
+
19
+ /**
20
+ * ⛔ I2 FIX (LAND red team, 2026-09-26) — NEVER LEAVE `path` UNDEFINED. An undefined `path` here
21
+ * made the stock `Kubernetes.KubeConfigAdapter` fall back to `$KUBECONFIG`/`~/.kube/config`
22
+ * (alchemy `Kubernetes/internal/kubeconfig.ts`'s `resolveKubeConfigPath`, wired in
23
+ * `BuiltinAdapters.ts`) — exactly the un-vaulted, possibly-stale-context exposure this whole
24
+ * family exists to remove. A consumer wiring `Kubernetes.*` to `kube.connection` (this
25
+ * attribute's own doc invites exactly that) would silently connect through whatever
26
+ * `admin@<cluster>` context happens to be on the operator's machine. `Kubernetes.ClusterAdapter`
27
+ * is not yet wired to `mintKubeconfig` (kubeconfig.ts's own header) — no real path exists for
28
+ * this connection to use — so it must fail LOUDLY instead of silently succeeding against a
29
+ * stranger's cluster. A path that can never resolve does that: `resolveKubeContext` fails at
30
+ * `fs.readFileString` naming this exact sentinel, instead of reading whatever real file happens
31
+ * to be on `$KUBECONFIG`.
32
+ */
33
+ const UNWIRED_KUBECONFIG_PATH =
34
+ '/talos-first-boot-unwired/wire-Kubernetes.ClusterAdapter-to-mintKubeconfig';
35
+
36
+ const toConnection = (props: KubeconfigProps): Connection => ({
37
+ auth: { context: props.context, kind: 'kubeconfig', path: UNWIRED_KUBECONFIG_PATH },
38
+ });
39
+
40
+ /** Parse `raw` kubeconfig YAML into public, persistable attributes, or `undefined` if it fails to. */
41
+ export const buildAttrs = (
42
+ raw: string,
43
+ props: KubeconfigProps,
44
+ ): KubeconfigAttributes | undefined => {
45
+ const meta = kubeconfigMetadata(raw, props.context);
46
+ if (meta === undefined) return undefined;
47
+ return {
48
+ certificateAuthorityFingerprint: meta.caFingerprint,
49
+ clientCertificateFingerprint: meta.clientFingerprint,
50
+ connection: toConnection(props),
51
+ context: props.context,
52
+ credentialGeneration: generation({ ...meta, context: props.context }),
53
+ endpoint: meta.endpoint,
54
+ };
55
+ };