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