@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
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `Proxmox.Storage`'s partial-update fix. Measured 2026-09-27, `bun run deploy`: PVE's PUT
3
+ * /storage/{storage} refused `storage-cephfs-tb4`'s update with `unexpected property 'shared'`,
4
+ * because `reconcile` used to PUT the FULL declared set whenever anything drifted — including a
5
+ * `shared` that already matched live, which cephfs's own plugin `options()` never accepts at all
6
+ * (storage-plugin-options.ts has the vendor citation). storage-form.ts's `updateForm` now sends
7
+ * only the fields that actually differ from a live read; these tests prove the PUT body shape
8
+ * directly, never touching a real PVE cluster.
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import * as Layer from 'effect/Layer';
12
+ import { engineOver } from '../verify/fake-engine.ts';
13
+ import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
14
+ import { createForm } from './storage-form.ts';
15
+ import { ProxmoxStorage, ProxmoxStorageProvider } from './storage.ts';
16
+
17
+ type Live = Record<string, string>;
18
+
19
+ /** `storage` (POST) and `storage/{id}` (GET/PUT), keyed by storage id — group.test.ts's shape. */
20
+ const cluster = (stores: Map<string, Live>) =>
21
+ fakePve((call: PveCall) => {
22
+ const match = /^storage(?:\/(.+))?$/.exec(call.path);
23
+ if (match === null) return undefined;
24
+ const id = match[1];
25
+ if (call.method === 'GET' && id !== undefined) return stores.get(id);
26
+ if (call.method === 'PUT' && id !== undefined) {
27
+ const live = stores.get(id);
28
+ if (live !== undefined) stores.set(id, { ...live, ...call.form });
29
+ }
30
+ return undefined;
31
+ });
32
+
33
+ const engineFor = (fake: ReturnType<typeof cluster>) =>
34
+ engineOver(ProxmoxStorageProvider().pipe(Layer.provideMerge(fake.layer)));
35
+
36
+ describe('Proxmox.Storage update: a partial PUT, never a property the type refuses', () => {
37
+ test('a cephfs storage whose only drift is content: PUT carries content and the id, never shared', async () => {
38
+ // MEASURED SHAPE: live already reports `shared: '1'` (cephfs is implicitly shared) and the
39
+ // declaration agrees — only `content` (Talos's `import`, the change that triggered the bug)
40
+ // actually differs.
41
+ const stores = new Map<string, Live>([
42
+ ['cephfs-tb4', { content: 'backup', shared: '1', storage: 'cephfs-tb4', type: 'cephfs' }],
43
+ ]);
44
+ const fake = cluster(stores);
45
+ const declare = () =>
46
+ ProxmoxStorage('cephfs', {
47
+ content: 'backup,import',
48
+ shared: true,
49
+ storage: 'cephfs-tb4',
50
+ target: FAKE_TARGET,
51
+ type: 'cephfs',
52
+ });
53
+ await withoutBao(async () => {
54
+ const engine = engineFor(fake);
55
+ const report = await engine.verify(declare());
56
+ expect(report.rows[0]).toMatchObject({ diff: 'update' });
57
+ expect(await engine.deploy(declare())).toEqual({ cephfs: 'adopted' });
58
+ });
59
+ // ⚠️ `storage` NAMES THE OBJECT VIA THE PUT'S OWN PATH (`storage/cephfs-tb4` here), NOT A
60
+ // BODY FIELD — distilled routes it as a path label (distilled-doctrine: Label -> path), so
61
+ // it never reaches `call.form`. `fake.writes()` is what proves the id.
62
+ expect(fake.writes()).toEqual(['PUT storage/cephfs-tb4']);
63
+ // ⛔ THE BUG THIS PROVES FIXED: before this fix the body was the full declared set, `shared`
64
+ // included — exactly what PVE refused with "unexpected property 'shared'".
65
+ expect(fake.calls.find((call) => call.method === 'PUT')?.form).toEqual({
66
+ content: 'backup,import',
67
+ });
68
+ });
69
+
70
+ test('no drift at all: no PUT is sent', async () => {
71
+ const stores = new Map<string, Live>([
72
+ ['cephfs-tb4', { content: 'backup,import', storage: 'cephfs-tb4', type: 'cephfs' }],
73
+ ]);
74
+ const fake = cluster(stores);
75
+ const declare = () =>
76
+ ProxmoxStorage('cephfs', {
77
+ content: 'backup,import',
78
+ storage: 'cephfs-tb4',
79
+ target: FAKE_TARGET,
80
+ type: 'cephfs',
81
+ });
82
+ await withoutBao(async () => {
83
+ const engine = engineFor(fake);
84
+ const report = await engine.verify(declare());
85
+ expect(report.rows[0]).toMatchObject({ diff: 'noop' });
86
+ // ⚠️ `'adopted'`, NOT `'noop'` — a first deploy of a matching object is always reported
87
+ // `adopted` (adopt-verify.md's own note on Plan.ts/Apply.ts: "the node is adopted either
88
+ // way"). The provider's own `diff` (asserted above) is what proves nothing changed;
89
+ // `fake.writes()` below is what proves `reconcile` actually wrote nothing.
90
+ expect(await engine.deploy(declare())).toEqual({ cephfs: 'adopted' });
91
+ });
92
+ expect(fake.writes()).toEqual([]);
93
+ });
94
+
95
+ test('a dir storage DOES accept `shared` on update: the PUT carries it', async () => {
96
+ // VENDOR EVIDENCE (storage-plugin-options.ts): `DirPlugin.pm`'s own `options()` names
97
+ // `shared => { optional => 1 }`, unlike cephfs — this is the "one more type" contrast case.
98
+ const stores = new Map<string, Live>([
99
+ ['local-extra', { content: 'iso', shared: '0', storage: 'local-extra', type: 'dir' }],
100
+ ]);
101
+ const fake = cluster(stores);
102
+ const declare = () =>
103
+ ProxmoxStorage('dir', {
104
+ content: 'iso',
105
+ shared: true,
106
+ storage: 'local-extra',
107
+ target: FAKE_TARGET,
108
+ type: 'dir',
109
+ });
110
+ await withoutBao(async () => {
111
+ const engine = engineFor(fake);
112
+ expect(await engine.deploy(declare())).toEqual({ dir: 'adopted' });
113
+ });
114
+ expect(fake.writes()).toEqual(['PUT storage/local-extra']);
115
+ expect(fake.calls.find((call) => call.method === 'PUT')?.form).toEqual({ shared: '1' });
116
+ });
117
+ });
118
+
119
+ /**
120
+ * PR 314's own red team (2026-09-27, Important finding): `changedProps` above leaves `shared`
121
+ * out of its result for any type outside `sharedAccepted`, on purpose — but that means a
122
+ * genuinely disagreeing declared value would otherwise vanish into an empty diff and report
123
+ * `noop` forever, with the recorded attribute silently stuck on whatever PVE already has. These
124
+ * two tests prove `matches` (storage-wire.ts) catches exactly that one case and no other.
125
+ */
126
+ describe('a declared `shared` a type can never apply dies the plan, never noops forever', () => {
127
+ test('zfspool (never accepts shared): a genuine disagreement dies rather than reporting noop', async () => {
128
+ const stores = new Map<string, Live>([
129
+ ['speed', { content: 'images', shared: '0', storage: 'speed', type: 'zfspool' }],
130
+ ]);
131
+ const fake = cluster(stores);
132
+ const declare = () =>
133
+ ProxmoxStorage('speed', {
134
+ content: 'images',
135
+ shared: true,
136
+ storage: 'speed',
137
+ target: FAKE_TARGET,
138
+ type: 'zfspool',
139
+ });
140
+ await withoutBao(async () => {
141
+ const engine = engineFor(fake);
142
+ await expect(engine.verify(declare())).rejects.toBeDefined();
143
+ });
144
+ expect(fake.writes()).toEqual([]);
145
+ });
146
+
147
+ test('zfspool: a declared shared that already agrees with live still plans noop, not a die', async () => {
148
+ const stores = new Map<string, Live>([
149
+ ['speed', { content: 'images', shared: '0', storage: 'speed', type: 'zfspool' }],
150
+ ]);
151
+ const fake = cluster(stores);
152
+ const declare = () =>
153
+ ProxmoxStorage('speed', {
154
+ content: 'images',
155
+ shared: false,
156
+ storage: 'speed',
157
+ target: FAKE_TARGET,
158
+ type: 'zfspool',
159
+ });
160
+ await withoutBao(async () => {
161
+ const engine = engineFor(fake);
162
+ const report = await engine.verify(declare());
163
+ expect(report.rows[0]).toMatchObject({ diff: 'noop' });
164
+ });
165
+ expect(fake.writes()).toEqual([]);
166
+ });
167
+ });
168
+
169
+ /** PR 314's own red team (Minor finding): the create path's own `shared` gate had no test. */
170
+ describe('createForm: `shared` is gated by type on create too, not only on update', () => {
171
+ test('a declared shared is dropped for cephfs, rbd and pbs, kept for dir, lvm, btrfs and esxi', () => {
172
+ for (const type of ['dir', 'lvm', 'btrfs', 'esxi']) {
173
+ expect(createForm({ shared: true, storage: 's', target: FAKE_TARGET, type })).toMatchObject({
174
+ shared: '1',
175
+ });
176
+ }
177
+ for (const type of ['cephfs', 'rbd', 'pbs']) {
178
+ expect(
179
+ createForm({ shared: true, storage: 's', target: FAKE_TARGET, type }),
180
+ ).not.toHaveProperty('shared');
181
+ }
182
+ });
183
+ });
@@ -7,6 +7,8 @@
7
7
  import * as storage from '@distilled.cloud/proxmox/storage';
8
8
  import * as Effect from 'effect/Effect';
9
9
  import { runPve } from './distilled-pve.ts';
10
+ import { sharedAccepted } from './storage-plugin-options.ts';
11
+ import type { MutableFields } from './storage-form.ts';
10
12
  import type { StorageProps } from './storage.ts';
11
13
  import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
12
14
  import { bool } from './values.ts';
@@ -109,15 +111,60 @@ export const readStorage = (props: StorageProps) =>
109
111
  export const dropUnreadable = (live: StorageAttributes | Unreadable | undefined) =>
110
112
  live === UNREADABLE ? undefined : live;
111
113
 
114
+ /**
115
+ * The declared fields (of the 7 `mutable()` — storage-form.ts — ever writes) whose value
116
+ * genuinely differs from what PVE just reported. `matches` below and storage-form.ts's
117
+ * `updateForm` share this one comparison, so the plan's noop/update verdict and the partial PUT
118
+ * body it sends can never disagree about what "changed" means for a list, a boolean or `shared`.
119
+ *
120
+ * ⛔ `shared` IS SKIPPED ENTIRELY FOR A TYPE THAT DOESN'T ACCEPT IT (storage-plugin-options.ts's
121
+ * `sharedAccepted`, vendor-cited there) — the same treatment `type` already gets below
122
+ * (create-only, never compared) and for the identical reason: PVE will never apply a `shared`
123
+ * PUT for cephfs/rbd/zfspool/… (it 500s, `unexpected property 'shared'` — the bug this family
124
+ * shipped with, measured 2026-09-27), so comparing it against a live value that can never move
125
+ * to match could only ever report `update` forever. Without this skip, `mutable()`'s own gate
126
+ * (storage-form.ts) would still keep `shared` out of the wire body, but `matches` would keep
127
+ * reporting drift for a field nothing can ever apply — a plan that never reaches noop.
128
+ */
129
+ export const changedProps = (
130
+ live: StorageAttributes,
131
+ props: StorageProps,
132
+ ): Partial<MutableFields> => ({
133
+ ...(sameList(props.content, live.content) ? {} : { content: props.content }),
134
+ ...(sameList(props.nodes, live.nodes) ? {} : { nodes: props.nodes }),
135
+ ...(sameList(props['prune-backups'], live['prune-backups'])
136
+ ? {}
137
+ : { 'prune-backups': props['prune-backups'] }),
138
+ ...(same(props.disable, live.disable) ? {} : { disable: props.disable }),
139
+ ...(sharedAccepted(props.type) && !same(props.shared, live.shared)
140
+ ? { shared: props.shared }
141
+ : {}),
142
+ ...(same(props.preallocation, live.preallocation) ? {} : { preallocation: props.preallocation }),
143
+ ...(same(props.comment, live.comment) ? {} : { comment: props.comment }),
144
+ });
145
+
112
146
  /**
113
147
  * ⚠️ `type` IS NEVER COMPARED — CREATE-ONLY (`storage.ts`'s own `StorageProps.type` doc), so a
114
148
  * changed declaration plans `noop` on it, same as before the migration.
149
+ *
150
+ * ⛔ A DECLARED `shared` A TYPE CAN NEVER APPLY MUST FAIL LOUD, NOT NOOP FOREVER — PR 314's own
151
+ * red team, 2026-09-27. `changedProps` above leaves `shared` out of its result whenever the
152
+ * type is outside `sharedAccepted`, on purpose (PVE 500s on the PUT otherwise) — but that
153
+ * means a genuinely disagreeing declared value would otherwise vanish into an empty diff and
154
+ * report `noop` forever, with the recorded attribute silently stuck on whatever PVE already
155
+ * has. `maxfiles` (storage.ts's header) refuses that same class of bug at compile time; this is
156
+ * the update-time equivalent for a field that can't be typed away. So this checks the ONE case
157
+ * `changedProps` deliberately can't see — declared and live actually differ, on a type that can
158
+ * never carry the write — and dies with a message naming the fix, before `changedProps` runs.
115
159
  */
116
160
  export const matches = (attributes: StorageAttributes, props: StorageProps) =>
117
- sameList(props.content, attributes.content) &&
118
- sameList(props.nodes, attributes.nodes) &&
119
- sameList(props['prune-backups'], attributes['prune-backups']) &&
120
- same(props.disable, attributes.disable) &&
121
- same(props.shared, attributes.shared) &&
122
- same(props.preallocation, attributes.preallocation) &&
123
- same(props.comment, attributes.comment);
161
+ !sharedAccepted(props.type) && !same(props.shared, attributes.shared)
162
+ ? Effect.die(
163
+ new Error(
164
+ `storage/${props.storage}: declared shared: ${String(props.shared)}, but PVE reports ` +
165
+ `${String(attributes.shared)} and '${props.type}' storages never accept a 'shared' ` +
166
+ 'write (storage-plugin-options.ts) -- remove the declared shared or set it to match ' +
167
+ 'what PVE already reports; this value can never be applied.',
168
+ ),
169
+ )
170
+ : Effect.succeed(Object.keys(changedProps(attributes, props)).length === 0);
@@ -112,7 +112,12 @@ export interface StorageProps extends WithTarget {
112
112
  nodes?: string;
113
113
  /** Keeps the definition but stops PVE using it. */
114
114
  disable?: boolean;
115
- /** Tells PVE the same volumes are visible from every node — a claim, not a mechanism. */
115
+ /**
116
+ * Tells PVE the same volumes are visible from every node — a claim, not a mechanism.
117
+ * ⚠️ ONLY SENT WHEN THE TYPE'S OWN PLUGIN ACCEPTS IT (storage-plugin-options.ts, vendor-cited)
118
+ * — elsewhere it is dropped from the wire, and a disagreeing declared value dies the plan
119
+ * loud (storage-wire.ts's `matches`) rather than PUTting a field PVE would refuse outright.
120
+ */
116
121
  shared?: boolean;
117
122
  /** `off` | `metadata` | `falloc` | `full`. File-based plugins only. */
118
123
  preallocation?: string;
@@ -166,7 +171,9 @@ export const ProxmoxStorageProvider = () =>
166
171
  diff: Effect.fn(function* ({ news, output }) {
167
172
  if (!isResolved(news)) return undefined;
168
173
  yield* guardWrite(STORAGE_CREATE, createForm(news), output === undefined);
169
- yield* guardWrite(STORAGE_UPDATE, updateForm(news), false);
174
+ // ⚠️ `live: undefined`: no read yet, so this only sanity-checks the FULL declared set
175
+ // (storage-form.ts's `updateForm` doc) — `reconcile` builds the actual partial body.
176
+ yield* guardWrite(STORAGE_UPDATE, updateForm(news, undefined), false);
170
177
  if (output === undefined) return undefined;
171
178
  // ⚠️ `readStorageOrFail`, NOT `readStorage` — see that function's own ⛔ in
172
179
  // storage-wire.ts. A genuine TRANSIENT failure here propagates and fails the whole
@@ -182,7 +189,7 @@ export const ProxmoxStorageProvider = () =>
182
189
  yield* guardWrite(STORAGE_CREATE, createForm(news), true);
183
190
  return { action: 'update' } as const;
184
191
  }
185
- return matches(live, news)
192
+ return (yield* matches(live, news))
186
193
  ? ({ action: 'noop' } as const)
187
194
  : ({ action: 'update' } as const);
188
195
  }),
@@ -192,7 +199,12 @@ export const ProxmoxStorageProvider = () =>
192
199
  // handles — group.ts's/user.ts's reconcile have the same note.
193
200
  const before = dropUnreadable(yield* readStorage(news));
194
201
  yield* guardWrite(STORAGE_CREATE, createForm(news), before === undefined);
195
- yield* guardWrite(STORAGE_UPDATE, updateForm(news), false);
202
+ // ★ COMPUTED ONCE, THEN REUSED FOR BOTH THE GUARD AND THE WRITE — storage-form.ts's own
203
+ // ⚠️ on `toDistilledUpdate`: `update` is the partial body (only fields that differ
204
+ // from `before`, and never `shared` for a type that doesn't accept it), so the
205
+ // constraint check below inspects exactly what the PUT, if any, will carry.
206
+ const update = updateForm(news, before);
207
+ yield* guardWrite(STORAGE_UPDATE, update, false);
196
208
  if (before === undefined) {
197
209
  yield* runPve(
198
210
  news.target,
@@ -200,12 +212,12 @@ export const ProxmoxStorageProvider = () =>
200
212
  true,
201
213
  storage.createStorage(toDistilledCreate(news)),
202
214
  );
203
- } else if (!matches(before, news)) {
215
+ } else if (!(yield* matches(before, news))) {
204
216
  yield* runPve(
205
217
  news.target,
206
218
  'provision',
207
219
  true,
208
- storage.putStorage(toDistilledUpdate(news)),
220
+ storage.putStorage(toDistilledUpdate(update)),
209
221
  );
210
222
  }
211
223
  const after = dropUnreadable(yield* readStorage(news));
@@ -37,6 +37,7 @@ export { VICTORIA_RELEASES } from './victoria.ts';
37
37
  export type {
38
38
  ExecResult,
39
39
  FileStat,
40
+ HostPlatform,
40
41
  HostRunner,
41
42
  HostUser,
42
43
  WriteOptions,
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `credentials-write.ts` — offline, fake `bao`, no real process ever spawns. Fixtures only.
3
+ */
4
+ import assert from 'node:assert/strict';
5
+ import { existsSync } from 'node:fs';
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 { reservedTempPath, writeKvValue } from './credentials-write.ts';
10
+ import { type FakeCall, fakeSpawner } from './fake-process.ts';
11
+
12
+ const run = <A, E>(
13
+ effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
14
+ handler: (c: FakeCall) => { stdout?: string; stderr?: string; exitCode?: number },
15
+ calls: FakeCall[] = [],
16
+ ) =>
17
+ Effect.runPromise(
18
+ Effect.provideService(
19
+ effect,
20
+ ChildProcessSpawner.ChildProcessSpawner,
21
+ fakeSpawner(handler, calls),
22
+ ),
23
+ );
24
+
25
+ describe('writeKvValue', () => {
26
+ it('sends the value on stdin, never in argv', async () => {
27
+ const calls: FakeCall[] = [];
28
+ await run(
29
+ writeKvValue('talos-c1', 'kubeconfig', 'kubeconfig', 'super-secret-kubeconfig-body'),
30
+ () => ({}),
31
+ calls,
32
+ );
33
+ assert.equal(calls.length, 1);
34
+ // ⛔ C2 (LAND red team): `=-`, never `=@-` — `@` means "read a file at this literal path",
35
+ // which OpenBao's own CLI rejects (or, worse, silently reads a file named `-` if one exists).
36
+ assert.deepEqual(calls[0]?.args, ['kv', 'put', 'talos-c1/kubeconfig', 'kubeconfig=-']);
37
+ assert.equal(calls[0]?.stdin, 'super-secret-kubeconfig-body');
38
+ // ⛔ THE POINT OF THE TEST: the value appears nowhere in argv.
39
+ assert.ok(!calls[0]?.args.some((arg) => arg.includes('super-secret-kubeconfig-body')));
40
+ });
41
+
42
+ it('fails on a non-zero exit and never echoes stdin in the error', async () => {
43
+ await assert.rejects(
44
+ run(writeKvValue('talos-c1', 'kubeconfig', 'kubeconfig', 'secret-body'), () => ({
45
+ exitCode: 2,
46
+ stderr: 'permission denied',
47
+ })),
48
+ (error: unknown) =>
49
+ error instanceof Error &&
50
+ error.message.includes('permission denied') &&
51
+ !error.message.includes('secret-body'),
52
+ );
53
+ });
54
+ });
55
+
56
+ describe('reservedTempPath', () => {
57
+ it('does not pre-create the file, and cleans up if something else wrote there', async () => {
58
+ let path = '';
59
+ await Effect.runPromise(
60
+ Effect.scoped(
61
+ Effect.gen(function* () {
62
+ const reserved = yield* reservedTempPath('c1-node-kubeconfig-out');
63
+ path = reserved.path;
64
+ assert.equal(existsSync(path), false, 'nothing has written here yet');
65
+ yield* Effect.promise(() =>
66
+ Bun.write(path, 'written by the caller, like talosctl would'),
67
+ );
68
+ assert.ok(existsSync(path));
69
+ }),
70
+ ),
71
+ );
72
+ assert.equal(existsSync(path), false, 'must be gone once the caller scope closed');
73
+ });
74
+
75
+ it('two calls never collide on the same path', async () => {
76
+ const [a, b] = await Effect.runPromise(
77
+ Effect.scoped(Effect.all([reservedTempPath('same-label'), reservedTempPath('same-label')])),
78
+ );
79
+ assert.notEqual(a.path, b.path);
80
+ });
81
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The write half of Talos's OpenBao KV access — split out of credentials.ts (2026-09-26,
3
+ * K-talos-first-boot) once it needed its own header, the same reason machine-config-read.ts,
4
+ * machine-config-poll.ts and talos-errors.ts were split out of talos-machine-config.ts before it.
5
+ * `credentials.ts` stays the read side (mint a talosconfig, read a KV value); this file is the one
6
+ * place anything in this package ever writes TO OpenBao.
7
+ *
8
+ * ★ FIRST CALLER: `Talos.Kubeconfig` (kubeconfig.ts) writing the admin kubeconfig it generates at
9
+ * cluster bring-up into the vault instead of an un-vaulted host `runtimePath` — see that file's
10
+ * own header. `Ceph.AuthEntity` (K-A4, a separate design/PR) is expected to need the same shape
11
+ * for its own vault-written entity keys; this is not yet shared with it on purpose — two callers
12
+ * is the point at which sharing stops being speculative.
13
+ */
14
+ import * as Effect from 'effect/Effect';
15
+ import type * as Scope from 'effect/Scope';
16
+ import * as Stream from 'effect/Stream';
17
+ import * as ChildProcess from 'effect/unstable/process/ChildProcess';
18
+ import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
19
+
20
+ /**
21
+ * A fresh, unguessable temp path for SOME OTHER PROCESS (talosctl) to create — unlike
22
+ * `credentials.ts`'s `mintKvTempFile`, this does NOT pre-create the file with `wx`, because the
23
+ * caller here is capturing a command's OWN output rather than writing known content itself. The
24
+ * random UUIDv7 name is the only pre-fabrication guard (an attacker cannot pre-plant a symlink at a
25
+ * path it cannot predict) — first used by `kubeconfig.ts` to capture `talosctl kubeconfig`'s
26
+ * generated file before reading it back and writing it into the vault.
27
+ */
28
+ export const reservedTempPath = (
29
+ label: string,
30
+ ): Effect.Effect<{ readonly path: string }, never, Scope.Scope> =>
31
+ Effect.gen(function* () {
32
+ const path = `${Bun.env['TMPDIR'] ?? '/tmp'}/hf-talos-${label}-${Bun.randomUUIDv7()}.yaml`;
33
+ yield* Effect.addFinalizer(() =>
34
+ Effect.tryPromise({ try: () => Bun.file(path).delete(), catch: () => undefined }).pipe(
35
+ Effect.orElseSucceed(() => undefined),
36
+ ),
37
+ );
38
+ return { path };
39
+ });
40
+
41
+ /**
42
+ * Write one field of an OpenBao KV-v2 value from `value` — never argv, the mirror image of
43
+ * `credentials.ts`'s `readKvValue`.
44
+ *
45
+ * ⛔ C2 FIX (LAND red team, 2026-09-26) — `field=@-` IS WRONG AND WAS NEVER MEASURED. `@value` means
46
+ * "read a file at this path"; a literal path of `-` is not the stdin convention for `@` — it is
47
+ * just a file named `-`. MEASURED against the estate's OpenBao v2.6.2 binary (isolated in-memory
48
+ * dev server, `env -i`, scratch HOME, no estate credentials involved; server removed after):
49
+ * `field=@-` exits 1 with `invalid key/value pair "field=@-": error reading file: open -: no
50
+ * such file or directory`, and if a file literally named `-` exists in the working directory it
51
+ * silently stores THAT file's bytes instead. `field=-` (no `@`) is Vault/OpenBao's own stdin
52
+ * convention and was confirmed against the same binary to read the piped bytes correctly. Every
53
+ * `Talos.Kubeconfig` create failed here — AFTER `talosctl kubeconfig` had already issued a fresh
54
+ * admin client certificate, which cannot be revoked.
55
+ * ⛔ stderr ONLY ON FAILURE — same rule as `readKvValue`: never echo what was being written, on
56
+ * success or failure.
57
+ */
58
+ export const writeKvValue = (
59
+ mount: string,
60
+ key: string,
61
+ field: string,
62
+ value: string,
63
+ ): Effect.Effect<void, Error, ChildProcessSpawner.ChildProcessSpawner> =>
64
+ Effect.gen(function* () {
65
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
66
+ const result = yield* ChildProcess.make('bao', ['kv', 'put', `${mount}/${key}`, `${field}=-`], {
67
+ detached: false,
68
+ extendEnv: true,
69
+ stderr: 'pipe',
70
+ stdin: Stream.fromIterable([new TextEncoder().encode(value)]),
71
+ stdout: 'pipe',
72
+ }).pipe(
73
+ spawner.spawn,
74
+ Effect.flatMap((child) =>
75
+ Effect.all(
76
+ {
77
+ exitCode: child.exitCode,
78
+ stderr: child.stderr.pipe(
79
+ Stream.decodeText,
80
+ Stream.mkString,
81
+ Effect.map((text) => text.trim()),
82
+ ),
83
+ },
84
+ { concurrency: 'unbounded' },
85
+ ),
86
+ ),
87
+ Effect.scoped,
88
+ );
89
+
90
+ if (result.exitCode !== 0) {
91
+ return yield* Effect.fail(
92
+ new Error(`bao kv put ${mount}/${key} exited ${String(result.exitCode)}: ${result.stderr}`),
93
+ );
94
+ }
95
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The C1 lifetime fix — pins that `mintTalosconfig`'s temp file survives until the CALLER's own
3
+ * `Effect.scoped` closes, not until `mintTalosconfig` itself returns (the shipped defect,
4
+ * credentials.ts's own header). Fully offline: fake-process.ts fakes `bao`, nothing real spawns.
5
+ *
6
+ * ⚠️ FIXTURES ONLY. No real OpenBao, no real talosconfig material — every "talosconfig" body
7
+ * below is a plain marker string, not YAML that could be mistaken for the real shape.
8
+ */
9
+ import assert from 'node:assert/strict';
10
+ import { existsSync, statSync } from 'node:fs';
11
+ import { describe, it } from 'node:test';
12
+ import * as Effect from 'effect/Effect';
13
+ import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
14
+ import { mintKubeconfig, mintKvTempFile, mintTalosconfig, readKvValue } from './credentials.ts';
15
+ import { type FakeCall, fakeSpawner } from './fake-process.ts';
16
+
17
+ const bao =
18
+ (data: Record<string, unknown>, exitCode = 0) =>
19
+ (call: FakeCall) => {
20
+ assert.equal(call.command, 'bao');
21
+ return exitCode === 0
22
+ ? { stdout: JSON.stringify({ data: { data } }) }
23
+ : { exitCode, stderr: 'permission denied' };
24
+ };
25
+
26
+ const run = <A, E>(
27
+ effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
28
+ handler: (c: FakeCall) => ReturnType<ReturnType<typeof bao>>,
29
+ calls: FakeCall[] = [],
30
+ ) =>
31
+ Effect.runPromise(
32
+ Effect.provideService(
33
+ effect,
34
+ ChildProcessSpawner.ChildProcessSpawner,
35
+ fakeSpawner(handler, calls),
36
+ ),
37
+ );
38
+
39
+ describe('mintTalosconfig — C1 lifetime fix', () => {
40
+ it('reads talosconfig/<mount> — the FIXED default key, not the doubled data/data path', async () => {
41
+ const calls: FakeCall[] = [];
42
+ let capturedPath = '';
43
+ await Effect.runPromise(
44
+ Effect.scoped(
45
+ Effect.gen(function* () {
46
+ const credential = yield* mintTalosconfig({ cluster: 'c1', mount: 'talos-c1' });
47
+ capturedPath = credential.talosconfigPath;
48
+ assert.ok(existsSync(capturedPath), 'file must exist while the caller scope is open');
49
+ }),
50
+ ).pipe(
51
+ Effect.provideService(
52
+ ChildProcessSpawner.ChildProcessSpawner,
53
+ fakeSpawner(bao({ talosconfig: 'fake-talosconfig-body' }), calls),
54
+ ),
55
+ ),
56
+ );
57
+ assert.equal(calls.length, 1);
58
+ assert.deepEqual(calls[0]?.args, ['kv', 'get', '-format=json', 'talos-c1/talosconfig']);
59
+ assert.equal(existsSync(capturedPath), false, 'must be gone once the caller scope closed');
60
+ });
61
+
62
+ it('is gone even when the caller scope is closed by a later failure (finalizers always run)', async () => {
63
+ let capturedPath = '';
64
+ const failing = Effect.scoped(
65
+ Effect.gen(function* () {
66
+ const credential = yield* mintTalosconfig({ cluster: 'c1', mount: 'talos-c1' });
67
+ capturedPath = credential.talosconfigPath;
68
+ return yield* Effect.fail(new Error('downstream talosctl call failed'));
69
+ }),
70
+ ).pipe(
71
+ Effect.provideService(
72
+ ChildProcessSpawner.ChildProcessSpawner,
73
+ fakeSpawner(bao({ talosconfig: 'fake-body' })),
74
+ ),
75
+ );
76
+ await assert.rejects(Effect.runPromise(failing));
77
+ assert.equal(existsSync(capturedPath), false);
78
+ });
79
+
80
+ it('honors a custom talosconfigKey without adding a data/ prefix', async () => {
81
+ const calls: FakeCall[] = [];
82
+ await run(
83
+ Effect.scoped(
84
+ mintTalosconfig({ cluster: 'c1', mount: 'talos-c1', talosconfigKey: 'context/admin' }),
85
+ ),
86
+ bao({ config: 'fallback-field-body' }),
87
+ calls,
88
+ );
89
+ assert.deepEqual(calls[0]?.args, ['kv', 'get', '-format=json', 'talos-c1/context/admin']);
90
+ });
91
+
92
+ it('fails without spawning talosctl or leaving a file when bao denies the read', async () => {
93
+ await assert.rejects(
94
+ run(Effect.scoped(mintTalosconfig({ cluster: 'c1', mount: 'talos-c1' })), bao({}, 1)),
95
+ );
96
+ });
97
+
98
+ it('fails when the KV entry has neither a talosconfig nor a config field', async () => {
99
+ await assert.rejects(
100
+ run(
101
+ Effect.scoped(mintTalosconfig({ cluster: 'c1', mount: 'talos-c1' })),
102
+ bao({ unrelated: 'x' }),
103
+ ),
104
+ );
105
+ });
106
+ });
107
+
108
+ describe('mintKvTempFile', () => {
109
+ it('writes exactly the given content at mode 0600', async () => {
110
+ let path = '';
111
+ await Effect.runPromise(
112
+ Effect.scoped(
113
+ Effect.gen(function* () {
114
+ const file = yield* mintKvTempFile('exact body\n', 'c1-node-config');
115
+ path = file.path;
116
+ const text = yield* Effect.promise(() => Bun.file(path).text());
117
+ assert.equal(text, 'exact body\n');
118
+ assert.equal(statSync(path).mode & 0o777, 0o600);
119
+ }),
120
+ ),
121
+ );
122
+ assert.equal(existsSync(path), false);
123
+ });
124
+ });
125
+
126
+ describe('mintKubeconfig — the consumer-side seam, same lifetime shape as mintTalosconfig', () => {
127
+ it('reads the default kubeconfig key and the file survives only within the caller scope', async () => {
128
+ const calls: FakeCall[] = [];
129
+ let capturedPath = '';
130
+ await Effect.runPromise(
131
+ Effect.scoped(
132
+ Effect.gen(function* () {
133
+ const file = yield* mintKubeconfig({ cluster: 'c1', mount: 'talos-c1' });
134
+ capturedPath = file.path;
135
+ assert.ok(existsSync(capturedPath));
136
+ }),
137
+ ).pipe(
138
+ Effect.provideService(
139
+ ChildProcessSpawner.ChildProcessSpawner,
140
+ fakeSpawner(bao({ kubeconfig: 'fake-kubeconfig-body' }), calls),
141
+ ),
142
+ ),
143
+ );
144
+ assert.deepEqual(calls[0]?.args, ['kv', 'get', '-format=json', 'talos-c1/kubeconfig']);
145
+ assert.equal(existsSync(capturedPath), false);
146
+ });
147
+ });
148
+
149
+ describe('readKvValue', () => {
150
+ it('tries fields in order and returns the first non-empty match', async () => {
151
+ const value = await run(
152
+ readKvValue('talos-c1', 'nodes/10001', ['config', 'legacyConfig']),
153
+ bao({ legacyConfig: 'second-field-body' }),
154
+ );
155
+ assert.equal(value, 'second-field-body');
156
+ });
157
+ });