@homeflare/alchemy 0.47.1 → 0.48.1

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 (130) hide show
  1. package/dist/talos/bao-env.d.ts +7 -0
  2. package/dist/talos/bao-env.d.ts.map +1 -0
  3. package/dist/talos/cluster-adapter-errors.d.ts +134 -0
  4. package/dist/talos/cluster-adapter-errors.d.ts.map +1 -0
  5. package/dist/talos/cluster-adapter.d.ts +116 -0
  6. package/dist/talos/cluster-adapter.d.ts.map +1 -0
  7. package/dist/talos/cluster-identity.d.ts +28 -0
  8. package/dist/talos/cluster-identity.d.ts.map +1 -0
  9. package/dist/talos/cluster-transport.d.ts +64 -0
  10. package/dist/talos/cluster-transport.d.ts.map +1 -0
  11. package/dist/talos/credentials-write.d.ts.map +1 -1
  12. package/dist/talos/credentials.d.ts +10 -19
  13. package/dist/talos/credentials.d.ts.map +1 -1
  14. package/dist/talos/fake-apiserver.d.ts +9 -0
  15. package/dist/talos/fake-apiserver.d.ts.map +1 -0
  16. package/dist/talos/index.d.ts +7 -0
  17. package/dist/talos/index.d.ts.map +1 -1
  18. package/dist/talos/index.js +683 -170
  19. package/dist/talos/index.js.map +22 -10
  20. package/dist/talos/kubeconfig-attrs.d.ts +14 -0
  21. package/dist/talos/kubeconfig-attrs.d.ts.map +1 -1
  22. package/dist/talos/kubeconfig-doc.d.ts +23 -0
  23. package/dist/talos/kubeconfig-doc.d.ts.map +1 -0
  24. package/dist/talos/kubeconfig.d.ts +0 -12
  25. package/dist/talos/kubeconfig.d.ts.map +1 -1
  26. package/dist/talos/literal-connection.d.ts +16 -0
  27. package/dist/talos/literal-connection.d.ts.map +1 -0
  28. package/dist/talos/machine-config-read.d.ts +1 -1
  29. package/dist/talos/machine-config-read.d.ts.map +1 -1
  30. package/dist/talos/mint-temp-file.d.ts +20 -0
  31. package/dist/talos/mint-temp-file.d.ts.map +1 -0
  32. package/dist/talos/node-temp.harness.d.ts +2 -0
  33. package/dist/talos/node-temp.harness.d.ts.map +1 -0
  34. package/dist/talos/talos-bootstrap-etcd.d.ts +1 -1
  35. package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -1
  36. package/dist/talos/talos-cluster-health.d.ts +15 -15
  37. package/dist/talos/talos-cluster-health.d.ts.map +1 -1
  38. package/dist/talos/talos-cluster-identity.d.ts +65 -0
  39. package/dist/talos/talos-cluster-identity.d.ts.map +1 -0
  40. package/dist/talos/talosctl-binary.d.ts +43 -0
  41. package/dist/talos/talosctl-binary.d.ts.map +1 -0
  42. package/dist/talos/talosctl.d.ts +29 -3
  43. package/dist/talos/talosctl.d.ts.map +1 -1
  44. package/dist/talos/trust-boundary.d.ts +8 -0
  45. package/dist/talos/trust-boundary.d.ts.map +1 -0
  46. package/dist/talos/values.d.ts.map +1 -1
  47. package/dist/talos/yaml-parse.d.ts +3 -0
  48. package/dist/talos/yaml-parse.d.ts.map +1 -0
  49. package/dist/verify/fake-engine.d.ts +2 -0
  50. package/dist/verify/fake-engine.d.ts.map +1 -1
  51. package/docs/talos-openbao-adapter.md +45 -0
  52. package/docs/upstream-conformance-bun.md +61 -24
  53. package/package.json +6 -2
  54. package/src/talos/bao-env.ts +16 -0
  55. package/src/talos/bao-vetting.test.ts +97 -0
  56. package/src/talos/cluster-adapter-deadline.test.ts +110 -0
  57. package/src/talos/cluster-adapter-errors.ts +193 -0
  58. package/src/talos/cluster-adapter-move.test.ts +131 -0
  59. package/src/talos/cluster-adapter-reconcile.test.ts +165 -0
  60. package/src/talos/cluster-adapter-refusals.test.ts +102 -0
  61. package/src/talos/cluster-adapter-rename.test.ts +87 -0
  62. package/src/talos/cluster-adapter-secrecy.test.ts +243 -0
  63. package/src/talos/cluster-adapter.fixtures.ts +125 -0
  64. package/src/talos/cluster-adapter.test.ts +240 -0
  65. package/src/talos/cluster-adapter.ts +250 -0
  66. package/src/talos/cluster-identity.ts +63 -0
  67. package/src/talos/cluster-transport.ts +110 -0
  68. package/src/talos/credentials-write.ts +27 -5
  69. package/src/talos/credentials.ts +51 -45
  70. package/src/talos/fake-apiserver.ts +55 -0
  71. package/src/talos/fake-process.ts +47 -1
  72. package/src/talos/index.ts +32 -0
  73. package/src/talos/kubeconfig-adapter.test.ts +37 -0
  74. package/src/talos/kubeconfig-attrs.ts +12 -28
  75. package/src/talos/kubeconfig-doc.ts +74 -0
  76. package/src/talos/kubeconfig.fixtures.ts +52 -0
  77. package/src/talos/kubeconfig.test.ts +6 -54
  78. package/src/talos/kubeconfig.ts +18 -34
  79. package/src/talos/literal-connection.ts +59 -0
  80. package/src/talos/mint-temp-file.test.ts +44 -0
  81. package/src/talos/mint-temp-file.ts +68 -0
  82. package/src/talos/node-connect.harness.ts +78 -0
  83. package/src/talos/node-connect.test.ts +27 -0
  84. package/src/talos/node-temp.harness.ts +40 -0
  85. package/src/talos/node-temp.test.ts +26 -0
  86. package/src/talos/talos-cluster-health-declare.test.ts +54 -0
  87. package/src/talos/talos-cluster-health-literal.test.ts +69 -0
  88. package/src/talos/talos-cluster-health.test.ts +104 -18
  89. package/src/talos/talos-cluster-health.ts +46 -14
  90. package/src/talos/talos-cluster-identity.test.ts +96 -0
  91. package/src/talos/talos-cluster-identity.ts +130 -0
  92. package/src/talos/talosctl-binary.ts +150 -0
  93. package/src/talos/talosctl-probe-kill.test.ts +56 -0
  94. package/src/talos/talosctl-vetting.test.ts +212 -0
  95. package/src/talos/talosctl.test.ts +163 -0
  96. package/src/talos/talosctl.ts +133 -15
  97. package/src/talos/trust-boundary.test.ts +17 -0
  98. package/src/talos/trust-boundary.ts +45 -0
  99. package/src/talos/values.ts +5 -12
  100. package/src/talos/yaml-parse.test.ts +41 -0
  101. package/src/talos/yaml-parse.ts +28 -0
  102. package/src/verify/fake-engine.ts +10 -0
  103. package/dist/talos/fake-process.d.ts +0 -56
  104. package/dist/talos/fake-process.d.ts.map +0 -1
  105. package/dist/talos/machine-config-fixtures.d.ts +0 -60
  106. package/dist/talos/machine-config-fixtures.d.ts.map +0 -1
  107. package/src/postgres/fixtures/pg-REL_18_6-acl.h.txt +0 -147
  108. package/src/postgres/fixtures/pg-REL_18_6-aclexplode-func.txt +0 -31
  109. package/src/postgres/fixtures/pg-REL_18_6-alter-default-privileges-synopsis.txt +0 -82
  110. package/src/postgres/fixtures/pg-REL_18_6-alter_role.sgml.txt +0 -44
  111. package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +0 -26
  112. package/src/postgres/fixtures/pg-REL_18_6-create_role.sgml.txt +0 -26
  113. package/src/postgres/fixtures/pg-REL_18_6-grant-synopsis.txt +0 -95
  114. package/src/postgres/fixtures/pg-REL_18_6-pg_attribute.h.txt +0 -24
  115. package/src/postgres/fixtures/pg-REL_18_6-pg_auth_members.h.txt +0 -62
  116. package/src/postgres/fixtures/pg-REL_18_6-pg_authid.h.txt +0 -70
  117. package/src/postgres/fixtures/pg-REL_18_6-pg_class.h.txt +0 -33
  118. package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +0 -67
  119. package/src/postgres/fixtures/pg-REL_18_6-pg_default_acl.h.txt +0 -81
  120. package/src/postgres/fixtures/pg-REL_18_6-pg_namespace.h.txt +0 -28
  121. package/src/postgres/fixtures/pg-REL_18_6-revoke-synopsis.txt +0 -124
  122. package/src/release/fixtures/openbao/checksums.txt +0 -83
  123. package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
  124. package/src/release/fixtures/openbao/darwin-assets.txt +0 -12
  125. package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +0 -109
  126. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +0 -22
  127. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +0 -2
  128. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +0 -2
  129. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +0 -2
  130. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +0 -8
@@ -0,0 +1,250 @@
1
+ /**
2
+ * `Kubernetes.ClusterAdapter` kind `talos-openbao`.
3
+ *
4
+ * Reads `mount/key` from OpenBao at connect time (`credentials.ts`'s `readKvValue`, which
5
+ * shells to `bao` with BAO_ADDR/BAO_TOKEN) and returns a `ClusterTransport`.
6
+ * ⛔ NOTHING IS WRITTEN TO DISK. ⛔ THE PERSISTED CONNECTION IS `{ kind, uid }` ONLY.
7
+ * alchemy `Kubernetes/Connection.ts` (v2.0.0-beta.79, lines 12-14) stores the Connection on
8
+ * every workload's attributes, so the upstream `client-cert` kind would put the admin PEM in
9
+ * the shared state store. The stock `kubeconfig` kind is also refused: an empty path falls
10
+ * back to `$KUBECONFIG`.
11
+ * ⛔ mount/key/context LIVE IN THE ADAPTER'S CONFIGURATION, NOT IN THE AUTH BLOCK. Upstream
12
+ * compares clusters by the auth block (`internal/workload.ts:91-96` `connectionIdentity`) and
13
+ * HelmChart/Manifest answer `replace` when it changes (`HelmChart.ts:248-258`,
14
+ * `Manifest.ts:194-209`); a replace creates first, then cleanup deletes the SAME-named objects.
15
+ * Renaming a vault key would silently delete Cilium, External Secrets and Gatekeeper.
16
+ * ⛔ THE IDENTITY RULE: THE AUTH BLOCK NAMES THE PHYSICAL CLUSTER. `uid` is the kube-system
17
+ * Namespace `metadata.uid` (public, stable across CA rotation). read/update/delete all
18
+ * reconnect from the SAVED auth block (`Manifest.ts:214` and `:267`, `HelmChart.ts:322` and
19
+ * `:335`, via `connectionOfOutput`, `internal/workload.ts:104-108`). A cluster NAME resolved
20
+ * through today's MUTABLE config would let a repointed `c1` PATCH one cluster and DELETE on
21
+ * another (reproduced through ManifestProvider). So the auth block carries the uid and NOTHING
22
+ * else: the config entry is looked up BY uid (no probing walk), `connect` fails closed BEFORE
23
+ * any vault read when the uid is absent or the row is legacy (`auth.cluster`), then GETs
24
+ * kube-system and compares before returning a transport. Missing, unreadable, unknown or
25
+ * different uid is a typed error — never `ClusterNotFoundError` (except a `retired` entry's
26
+ * absent key), which upstream turns into a silent no-op. Cost: one extra GET per connect.
27
+ * ⛔ BOOTSTRAP-THEN-PIN. A brand-new cluster has no uid until it exists, and an Output uid is
28
+ * forbidden (`talosOpenBaoConnection`). So: (1) the first deploy creates the cluster and
29
+ * `Talos.ClusterIdentity` (which reads and outputs the uid) with NO workloads; (2) the operator
30
+ * pins that printed uid literal in this adapter's config; (3) workloads deploy afterwards with
31
+ * `talosOpenBaoConnection(<that literal>)`. A uid that is not pinned is refused at connect.
32
+ * ⚠️ A REAL MOVE IS NOT AN AUTOMATIC REPLACE. `Talos.ClusterIdentity` refuses a changed uid
33
+ * (`TalosClusterMoved`); a move is a NEW identity resource per physical cluster, so the old
34
+ * rows keep their old uid and their cleanup reaches only the old cluster.
35
+ * ⛔ ONE 10 s DEADLINE covers the vault read AND the uid GET (`withConnectDeadline`): a hung `bao`
36
+ * fails with `TalosOpenBaoConnectTimeout` and the `bao` child is killed. The uid GET has its own
37
+ * 5 s deadline inside it, so a silent apiserver fails first with `TalosClusterIdentityTimeout`.
38
+ * ⚠️ The in-flight HTTPS GET is NOT aborted: upstream `readObject` takes no signal
39
+ * (`internal/client.ts:75`), so the socket lives until the OS gives up. Left for the upstream ask.
40
+ *
41
+ * USAGE RULES (upstream behaviour this adapter cannot fix; full text in
42
+ * `docs/talos-openbao-adapter.md`):
43
+ * 1. Move a workload to another cluster ONLY by new logical IDs (destroy + create), never by
44
+ * changing its connection in place.
45
+ * 2. Manage namespaces as separate `Manifest`s, never HelmChart `createNamespace`.
46
+ * 3. Never rename a HelmChart `releaseName` in place.
47
+ *
48
+ * Walked against alchemy 2.0.0-beta.79 `ClusterAdapter.ts` / `BuiltinAdapters.ts` and the OpenBao
49
+ * v2.6.2 absence string in `isVaultKeyAbsent`. Fake `bao` and fake apiserver only.
50
+ */
51
+ import {
52
+ ClusterAdapter,
53
+ type ClusterAdapterService,
54
+ ClusterNotFoundError,
55
+ type ClusterTransport,
56
+ } from 'alchemy/Kubernetes/ClusterAdapter';
57
+ import type { Connection } from 'alchemy/Kubernetes/Connection';
58
+ import type * as Duration from 'effect/Duration';
59
+ import * as Effect from 'effect/Effect';
60
+ import * as Layer from 'effect/Layer';
61
+ import type * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
62
+ import {
63
+ type TalosClusterIdentityMismatch,
64
+ TalosClusterIdentityMissing,
65
+ type TalosClusterIdentityTimeout,
66
+ type TalosClusterIdentityUnreadable,
67
+ type TalosKubeconfigUnreadable,
68
+ TalosOpenBaoAmbiguousUid,
69
+ TalosOpenBaoAuthKind,
70
+ type TalosOpenBaoConnectTimeout,
71
+ TalosOpenBaoLegacyAuth,
72
+ TalosOpenBaoUnknownCluster,
73
+ TalosUidNotLiteral,
74
+ type TalosVaultKeyMissing,
75
+ } from './cluster-adapter-errors.ts';
76
+ import { assertClusterUid } from './cluster-identity.ts';
77
+ import {
78
+ CONNECT_TIMEOUT,
79
+ type TalosOpenBaoCluster,
80
+ openTransport,
81
+ withConnectDeadline,
82
+ } from './cluster-transport.ts';
83
+
84
+ export {
85
+ TalosClusterIdentityMismatch,
86
+ TalosClusterIdentityMissing,
87
+ TalosClusterIdentityTimeout,
88
+ TalosClusterIdentityUnreadable,
89
+ TalosClusterMoved,
90
+ TalosKubeconfigUnreadable,
91
+ TalosOpenBaoAmbiguousUid,
92
+ TalosOpenBaoAuthKind,
93
+ TalosOpenBaoConnectTimeout,
94
+ TalosOpenBaoLegacyAuth,
95
+ TalosOpenBaoUnknownCluster,
96
+ TalosUidNotLiteral,
97
+ TalosVaultKeyMissing,
98
+ } from './cluster-adapter-errors.ts';
99
+ export { type TalosOpenBaoCluster, talosOpenBaoCluster } from './cluster-transport.ts';
100
+
101
+ declare module 'alchemy/Kubernetes/Connection' {
102
+ interface AuthRegistry {
103
+ /**
104
+ * Admin kubeconfig in OpenBao KV, read when a workload connects. Contributed by
105
+ * {@link TalosOpenBaoAdapter}, which holds the mount, key and context.
106
+ */
107
+ 'talos-openbao': {
108
+ /**
109
+ * The cluster's kube-system `metadata.uid`: the ONLY identity, a LITERAL string known at
110
+ * plan time (never an Output, see {@link talosOpenBaoConnection}). A row saved before it
111
+ * existed has none at runtime and is refused at connect.
112
+ */
113
+ uid: string;
114
+ };
115
+ }
116
+ }
117
+
118
+ /**
119
+ * ⛔ KEYED BY ALIAS, BUT THE ALIAS IS NEVER PERSISTED OR LOOKED UP BY. `connect` finds the entry
120
+ * whose pinned `uid` equals the saved auth block's, so renaming an alias changes nothing upstream
121
+ * hashes. Unknown uid is {@link TalosOpenBaoUnknownCluster}: refused, not another cluster's key.
122
+ */
123
+ export type TalosOpenBaoConfig = Readonly<Record<string, TalosOpenBaoCluster>>;
124
+
125
+ /** A `Connection` narrowed to this kind, so a PEM-bearing connection cannot be stored by type. */
126
+ export type TalosOpenBaoConnection = Connection & {
127
+ readonly auth: { readonly kind: 'talos-openbao'; readonly uid: string };
128
+ };
129
+
130
+ /**
131
+ * Serializable connection: kind and uid. No alias, vault path, endpoint, CA or cert.
132
+ * ⛔ THE UID MUST BE A LITERAL. An Output (a resource attribute such as `Talos.ClusterIdentity`'s)
133
+ * is unresolved at plan time, so `isResolved(news)` is false, upstream plans an UPDATE (Plan.ts)
134
+ * and reconcile replays the OLD cluster's `previousObjects` as deletes on the NEW cluster
135
+ * (reproduced: DELETE on B). Take the uid from the pinned adapter entry: a changed literal is a
136
+ * changed auth block, which upstream answers with `replace`. Anything else throws
137
+ * {@link TalosUidNotLiteral} while the stack is being declared.
138
+ */
139
+ export const talosOpenBaoConnection = (uid: string): TalosOpenBaoConnection => {
140
+ if (typeof uid !== 'string' || uid === '') throw new TalosUidNotLiteral({});
141
+ return { auth: { kind: 'talos-openbao', uid } };
142
+ };
143
+
144
+ /**
145
+ * Resolve one `talos-openbao` connection to a transport. Requires `ChildProcessSpawner`
146
+ * because the vault read shells to `bao`, and reaches the apiserver once for the identity check.
147
+ *
148
+ * ⚠️ A key the vault reports absent is `ClusterNotFoundError` ONLY for a cluster configured
149
+ * `retired: true` (upstream `read`/`delete` treat that as "everything in-cluster is already
150
+ * gone", so tearing down a destroyed cluster is not stuck). Otherwise it is the typed
151
+ * `TalosVaultKeyMissing`. Neither message names document bytes.
152
+ */
153
+ export const connectTalosOpenBao = (
154
+ configs: TalosOpenBaoConfig,
155
+ connection: Connection,
156
+ timeout: Duration.Duration = CONNECT_TIMEOUT,
157
+ ): Effect.Effect<
158
+ ClusterTransport,
159
+ | TalosOpenBaoAuthKind
160
+ | TalosOpenBaoUnknownCluster
161
+ | TalosOpenBaoAmbiguousUid
162
+ | TalosOpenBaoLegacyAuth
163
+ | TalosClusterIdentityMissing
164
+ | TalosClusterIdentityMismatch
165
+ | TalosClusterIdentityUnreadable
166
+ | TalosClusterIdentityTimeout
167
+ | TalosOpenBaoConnectTimeout
168
+ | TalosVaultKeyMissing
169
+ | ClusterNotFoundError
170
+ | TalosKubeconfigUnreadable
171
+ | Error,
172
+ ChildProcessSpawner.ChildProcessSpawner
173
+ > =>
174
+ Effect.gen(function* () {
175
+ // ⛔ FAIL CLOSED BEFORE ANY DESTRUCTURING: a saved row missing its whole connection, or just
176
+ // its auth block, names no physical cluster — a typed refusal, never a raw TypeError.
177
+ if (connection === undefined || connection === null) {
178
+ return yield* Effect.fail(new TalosClusterIdentityMissing({}));
179
+ }
180
+ const { auth } = connection;
181
+ if (auth === undefined || auth === null) {
182
+ return yield* Effect.fail(new TalosClusterIdentityMissing({}));
183
+ }
184
+ if (auth.kind !== 'talos-openbao') {
185
+ return yield* Effect.fail(new TalosOpenBaoAuthKind({ kind: auth.kind }));
186
+ }
187
+ // ⛔ Before the vault is touched. A row saved with an alias is refused even if it also has a
188
+ // uid: no silent migration, the saved state is edited deliberately (the error says how).
189
+ if (Object.hasOwn(auth, 'cluster')) {
190
+ return yield* Effect.fail(new TalosOpenBaoLegacyAuth({}));
191
+ }
192
+ const { uid } = auth;
193
+ if (typeof uid !== 'string' || uid === '') {
194
+ return yield* Effect.fail(new TalosClusterIdentityMissing({}));
195
+ }
196
+ const matches = Object.entries(configs).filter(([, entry]) => entry.uid === uid);
197
+ const [match] = matches;
198
+ if (match === undefined) {
199
+ return yield* Effect.fail(
200
+ new TalosOpenBaoUnknownCluster({
201
+ known: Object.values(configs).map((entry) => entry.uid),
202
+ uid,
203
+ }),
204
+ );
205
+ }
206
+ // ⛔ Two entries claiming one uid is a configuration error: refuse, never pick one.
207
+ if (matches.length > 1) {
208
+ return yield* Effect.fail(
209
+ new TalosOpenBaoAmbiguousUid({ aliases: matches.map(([alias]) => alias), uid }),
210
+ );
211
+ }
212
+ const [alias, config] = match;
213
+ // ⛔ A retired pin means the cluster is gone: no vault read, so destroying its rows completes.
214
+ if (config.retired === true) {
215
+ return yield* Effect.fail(
216
+ new ClusterNotFoundError({ message: `talos-openbao cluster '${alias}' is retired` }),
217
+ );
218
+ }
219
+ // ⛔ One deadline over the vault read AND the uid GET (`withConnectDeadline`).
220
+ return yield* withConnectDeadline(
221
+ alias,
222
+ Effect.gen(function* () {
223
+ const transport = yield* openTransport(config);
224
+ yield* assertClusterUid(alias, uid, transport);
225
+ return transport;
226
+ }),
227
+ timeout,
228
+ );
229
+ });
230
+
231
+ /**
232
+ * Register kind `talos-openbao`. Merge with `Kubernetes.providers()`:
233
+ * `Layer.mergeAll(Kubernetes.providers(), TalosOpenBaoAdapter({ c1: talosOpenBaoCluster(kc1) }))`
234
+ * where `kc1` is the props object handed to `Talos.Kubeconfig`.
235
+ */
236
+ export const TalosOpenBaoAdapter = (
237
+ config: TalosOpenBaoConfig,
238
+ ): Layer.Layer<ClusterAdapterService, never, ChildProcessSpawner.ChildProcessSpawner> =>
239
+ Layer.effect(
240
+ ClusterAdapter('talos-openbao'),
241
+ Effect.gen(function* () {
242
+ const context = yield* Effect.context<ChildProcessSpawner.ChildProcessSpawner>();
243
+ const service: ClusterAdapterService = {
244
+ kind: 'Kubernetes.ClusterAdapter',
245
+ connect: (connection) =>
246
+ connectTalosOpenBao(config, connection).pipe(Effect.provideContext(context)),
247
+ };
248
+ return service;
249
+ }),
250
+ );
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Which PHYSICAL cluster answers — the kube-system Namespace's `metadata.uid`.
3
+ *
4
+ * ★ WHY THE UID. It is public, assigned once at cluster creation and stable across CA and
5
+ * admin-cert rotation (a CA fingerprint is not: rotation would read as "a different cluster").
6
+ * Every cluster has the namespace, and the admin kubeconfig can read it.
7
+ * ★ The GET goes through alchemy's own `readObject` (`Kubernetes/internal/client.ts`, reached
8
+ * through the package's `./*` export) so TLS, CA and client-cert handling stay upstream's.
9
+ */
10
+ import type { ClusterTransport } from 'alchemy/Kubernetes/ClusterAdapter';
11
+ import { readObject } from 'alchemy/Kubernetes/internal/client';
12
+ import * as Duration from 'effect/Duration';
13
+ import * as Effect from 'effect/Effect';
14
+ import {
15
+ TalosClusterIdentityMismatch,
16
+ TalosClusterIdentityTimeout,
17
+ TalosClusterIdentityUnreadable,
18
+ } from './cluster-adapter-errors.ts';
19
+
20
+ const KUBE_SYSTEM = { apiVersion: 'v1', kind: 'Namespace', name: 'kube-system' } as const;
21
+
22
+ /**
23
+ * ⛔ upstream `readObject` (`internal/client.ts`) sets no request timeout, so a silent apiserver
24
+ * would hang connect and every plan. Bounded here; the failure is typed and closed.
25
+ * ⛔ SHORTER THAN `CONNECT_TIMEOUT` (10 s, cluster-transport.ts): the outer deadline wraps the vault
26
+ * read AND this uid GET together, so an equal inner deadline could never fire first and
27
+ * `TalosClusterIdentityTimeout` was unreachable through `connectTalosOpenBao` (red team, PR 355).
28
+ * A silent apiserver now fails with the identity timeout while a slow `bao` still gets the full
29
+ * outer budget.
30
+ */
31
+ export const UID_READ_TIMEOUT = Duration.seconds(5);
32
+
33
+ /** The cluster's kube-system uid. Request failures propagate: nothing here fails open. */
34
+ export const readClusterUid = (
35
+ cluster: string,
36
+ transport: ClusterTransport,
37
+ timeout: Duration.Duration = UID_READ_TIMEOUT,
38
+ ) =>
39
+ readObject({ object: KUBE_SYSTEM, transport }).pipe(
40
+ Effect.timeoutOrElse({
41
+ duration: timeout,
42
+ orElse: () =>
43
+ Effect.fail(
44
+ new TalosClusterIdentityTimeout({ cluster, seconds: Duration.toSeconds(timeout) }),
45
+ ),
46
+ }),
47
+ Effect.flatMap((body) => {
48
+ const uid = (body as { metadata?: { uid?: unknown } } | undefined)?.metadata?.uid;
49
+ return typeof uid === 'string' && uid !== ''
50
+ ? Effect.succeed(uid)
51
+ : Effect.fail(new TalosClusterIdentityUnreadable({ cluster }));
52
+ }),
53
+ );
54
+
55
+ /** Succeeds only when the answering cluster is the one `expected` names. */
56
+ export const assertClusterUid = (cluster: string, expected: string, transport: ClusterTransport) =>
57
+ readClusterUid(cluster, transport).pipe(
58
+ Effect.flatMap((got) =>
59
+ got === expected
60
+ ? Effect.void
61
+ : Effect.fail(new TalosClusterIdentityMismatch({ cluster, expected, got })),
62
+ ),
63
+ );
@@ -0,0 +1,110 @@
1
+ /**
2
+ * One cluster's vault location and the in-memory read of its admin kubeconfig, shared by the
3
+ * `talos-openbao` adapter (which then verifies identity) and `Talos.ClusterIdentity` (which
4
+ * reads the uid the adapter will later demand).
5
+ */
6
+ import type { ClusterTransport } from 'alchemy/Kubernetes/ClusterAdapter';
7
+ import * as Duration from 'effect/Duration';
8
+ import * as Effect from 'effect/Effect';
9
+ import {
10
+ TalosKubeconfigUnreadable,
11
+ TalosOpenBaoConnectTimeout,
12
+ TalosVaultKeyMissing,
13
+ } from './cluster-adapter-errors.ts';
14
+ import { DEFAULT_KUBECONFIG_KEY, isVaultKeyAbsent, readKvValue } from './credentials.ts';
15
+ import { kubeconfigTransport } from './kubeconfig-doc.ts';
16
+ import type { KubeconfigProps } from './kubeconfig.ts';
17
+
18
+ /** Where one cluster's admin kubeconfig lives in the vault. Configuration; never persisted. */
19
+ export type TalosVaultLocation = {
20
+ /** OpenBao KV mount, e.g. `talos-c1`. */
21
+ readonly mount: string;
22
+ /** Path under the mount. `Talos.Kubeconfig` writes `kubeconfig` unless `kubeconfigKey` is set. */
23
+ readonly key: string;
24
+ /** Context name inside the kubeconfig. Not secret. */
25
+ readonly context: string;
26
+ };
27
+
28
+ /**
29
+ * One adapter entry: a vault location PINNED to the physical cluster it must answer as.
30
+ * ⛔ `uid` is the kube-system `metadata.uid`. The persisted auth block carries only this, and
31
+ * connect looks the entry up BY it, so renaming the alias, vault key, mount or context changes
32
+ * nothing upstream hashes (`internal/workload.ts:91-96` `connectionIdentity`). Connect still
33
+ * proves the answering cluster has this uid before it returns a transport.
34
+ */
35
+ export type TalosOpenBaoCluster = TalosVaultLocation & {
36
+ readonly uid: string;
37
+ /**
38
+ * ⛔ ONLY `true` MAKES THIS UID MEAN "THE CLUSTER IS GONE": connect answers `ClusterNotFoundError`
39
+ * (upstream read/delete treat it as "everything in-cluster is already gone") WITHOUT a vault
40
+ * read, so destroying a dead cluster's rows completes. Otherwise an absent key is the loud
41
+ * `TalosVaultKeyMissing`: a typo in mount/key must not make `destroy` a silent no-op.
42
+ */
43
+ readonly retired?: boolean;
44
+ };
45
+
46
+ /**
47
+ * ★ ONE SOURCE OF TRUTH FOR mount/key/context: build the location from the SAME props the
48
+ * `Talos.Kubeconfig` resource writes with (`target.mount`, `kubeconfigKey`, `context`), so the
49
+ * writer and the reader cannot disagree.
50
+ */
51
+ export const talosVaultLocation = (
52
+ props: Pick<KubeconfigProps, 'context' | 'kubeconfigKey' | 'target'>,
53
+ ): TalosVaultLocation => ({
54
+ context: props.context,
55
+ key: props.kubeconfigKey ?? DEFAULT_KUBECONFIG_KEY,
56
+ mount: props.target.mount,
57
+ });
58
+
59
+ /** An adapter entry: {@link talosVaultLocation} plus the uid it is pinned to. */
60
+ export const talosOpenBaoCluster = (
61
+ props: Pick<KubeconfigProps, 'context' | 'kubeconfigKey' | 'target'>,
62
+ pin: { readonly uid: string; readonly retired?: boolean },
63
+ ): TalosOpenBaoCluster => ({
64
+ ...talosVaultLocation(props),
65
+ uid: pin.uid,
66
+ ...(pin.retired === true ? { retired: true } : {}),
67
+ });
68
+
69
+ /**
70
+ * ⛔ ONE DEADLINE FOR THE WHOLE CONNECT PATH. `bao kv get` has no timeout of its own, and the
71
+ * uid GET's bound (`cluster-identity.ts`) only starts after the vault read returns, so a hung
72
+ * `bao` hung connect and every plan. Interrupting the effect closes the spawn scope, which
73
+ * signals the child's process group (`NodeChildProcessSpawner` `terminateProcessGroup`).
74
+ */
75
+ export const CONNECT_TIMEOUT = Duration.seconds(10);
76
+
77
+ export const withConnectDeadline = <A, E, R>(
78
+ cluster: string,
79
+ effect: Effect.Effect<A, E, R>,
80
+ timeout: Duration.Duration = CONNECT_TIMEOUT,
81
+ ) =>
82
+ effect.pipe(
83
+ Effect.timeoutOrElse({
84
+ duration: timeout,
85
+ orElse: () =>
86
+ Effect.fail(
87
+ new TalosOpenBaoConnectTimeout({ cluster, seconds: Duration.toSeconds(timeout) }),
88
+ ),
89
+ }),
90
+ );
91
+
92
+ /**
93
+ * Vault read and parse, in memory. ⛔ No identity check here: callers decide what to trust.
94
+ * ⚠️ Only a document that does not parse is `TalosKubeconfigUnreadable`; a parser or runtime
95
+ * failure (a missing global, a broken import) is a defect and propagates as one.
96
+ */
97
+ export const openTransport = (config: TalosVaultLocation) =>
98
+ Effect.gen(function* () {
99
+ const { context, key, mount } = config;
100
+ const raw = yield* readKvValue(mount, key, ['kubeconfig', 'config']).pipe(
101
+ Effect.mapError((error) =>
102
+ isVaultKeyAbsent(error) ? new TalosVaultKeyMissing({ key, mount }) : error,
103
+ ),
104
+ );
105
+ const material = yield* Effect.sync(() => kubeconfigTransport(raw, context));
106
+ if (material === undefined) {
107
+ return yield* Effect.fail(new TalosKubeconfigUnreadable({ context, key, mount }));
108
+ }
109
+ return { ...material, headers: Effect.succeed({}) } satisfies ClusterTransport;
110
+ });
@@ -13,9 +13,16 @@
13
13
  */
14
14
  import * as Effect from 'effect/Effect';
15
15
  import type * as Scope from 'effect/Scope';
16
+ import * as Duration from 'effect/Duration';
16
17
  import * as Stream from 'effect/Stream';
18
+ import { unlink } from 'node:fs/promises';
19
+ import { randomUUID } from 'node:crypto';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
17
22
  import * as ChildProcess from 'effect/process/ChildProcess';
18
23
  import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
24
+ import { baoEnv } from './bao-env.ts';
25
+ import { resolveBao } from './talosctl-binary.ts';
19
26
 
20
27
  /**
21
28
  * A fresh, unguessable temp path for SOME OTHER PROCESS (talosctl) to create — unlike
@@ -29,9 +36,9 @@ export const reservedTempPath = (
29
36
  label: string,
30
37
  ): Effect.Effect<{ readonly path: string }, never, Scope.Scope> =>
31
38
  Effect.gen(function* () {
32
- const path = `${Bun.env['TMPDIR'] ?? '/tmp'}/hf-talos-${label}-${Bun.randomUUIDv7()}.yaml`;
39
+ const path = join(tmpdir(), `hf-talos-${label}-${randomUUID()}.yaml`);
33
40
  yield* Effect.addFinalizer(() =>
34
- Effect.tryPromise({ try: () => Bun.file(path).delete(), catch: () => undefined }).pipe(
41
+ Effect.tryPromise({ try: () => unlink(path), catch: () => undefined }).pipe(
35
42
  Effect.orElseSucceed(() => undefined),
36
43
  ),
37
44
  );
@@ -63,9 +70,24 @@ export const writeKvValue = (
63
70
  ): Effect.Effect<void, Error, ChildProcessSpawner.ChildProcessSpawner> =>
64
71
  Effect.gen(function* () {
65
72
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
66
- const result = yield* ChildProcess.make('bao', ['kv', 'put', `${mount}/${key}`, `${field}=-`], {
67
- detached: false,
68
- extendEnv: true,
73
+ // ⛔ Same vetting as `readKvValue`: `bao` writes credentials, so it is resolved and vetted before
74
+ // any env is built or passed — a fake `bao` in a writable PATH folder is refused, never launched.
75
+ const bao = yield* resolveBao();
76
+ const env = baoEnv();
77
+ if (env === undefined) {
78
+ return yield* Effect.fail(
79
+ new Error(
80
+ `bao kv put ${mount}/${key} refused: BAO_ADDR and BAO_TOKEN must both be set. ` +
81
+ "Without them bao would fall back to a cached login in the operator's home.",
82
+ ),
83
+ );
84
+ }
85
+ const result = yield* ChildProcess.make(bao, ['kv', 'put', `${mount}/${key}`, `${field}=-`], {
86
+ env,
87
+ extendEnv: false,
88
+ // ⛔ Mirror readKvValue's spawn: `bao` leads its own process group (killed whole on interrupt)
89
+ // and a SIGTERM-ignoring `bao` is SIGKILLed 1 s later instead of blocking the plan.
90
+ forceKillAfter: Duration.seconds(1),
69
91
  stderr: 'pipe',
70
92
  stdin: Stream.fromIterable([new TextEncoder().encode(value)]),
71
93
  stdout: 'pipe',
@@ -30,12 +30,17 @@
30
30
  * assigns, and `bao kv get` inserts the KV-v2 `data/` API segment itself — a key already
31
31
  * prefixed `data/` reads `<mount>/data/data/<key>`, which the OLD default did.
32
32
  */
33
- import { closeSync, openSync, writeFileSync } from 'node:fs';
33
+ import * as Duration from 'effect/Duration';
34
34
  import * as Effect from 'effect/Effect';
35
35
  import type * as Scope from 'effect/Scope';
36
36
  import * as Stream from 'effect/Stream';
37
37
  import * as ChildProcess from 'effect/process/ChildProcess';
38
38
  import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
39
+ import { baoEnv } from './bao-env.ts';
40
+ import { resolveBao } from './talosctl-binary.ts';
41
+ import { mintKvTempFile } from './mint-temp-file.ts';
42
+
43
+ export { mintKvTempFile } from './mint-temp-file.ts';
39
44
 
40
45
  /** Where credentials come from. HomeFlare-specific mount names live in the stack, not here. */
41
46
  export type TalosTarget = {
@@ -81,10 +86,34 @@ export const readKvValue = (
81
86
  ): Effect.Effect<string, Error, ChildProcessSpawner.ChildProcessSpawner> =>
82
87
  Effect.gen(function* () {
83
88
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
89
+ // ⛔ `bao` fetches the cluster credentials, so it is resolved and VETTED before any env is built
90
+ // or passed: a fake `bao` in a group/world-writable PATH folder is refused here, never launched.
91
+ const bao = yield* resolveBao();
92
+ const env = baoEnv();
93
+ if (env === undefined) {
94
+ return yield* Effect.fail(
95
+ new Error(
96
+ `bao kv get ${mount}/${key} refused: BAO_ADDR and BAO_TOKEN must both be set. ` +
97
+ "Without them bao would fall back to a cached login in the operator's home.",
98
+ ),
99
+ );
100
+ }
84
101
  const result = yield* ChildProcess.make(
85
- 'bao',
102
+ bao,
86
103
  ['kv', 'get', '-format=json', `${mount}/${key}`],
87
- { detached: false, extendEnv: true, stderr: 'pipe', stdin: 'ignore', stdout: 'pipe' },
104
+ // ⛔ NO `detached: false`, AND `forceKillAfter` IS SET (round-4 review). Effect detaches on
105
+ // Unix so `bao` leads its own process group, and the scope finalizer signals the WHOLE group
106
+ // (`NodeChildProcessSpawner`: `kill(-pid)`). With `detached: false` a `bao` that forked a
107
+ // sleeper left the grandchild alive, and without `forceKillAfter` a `bao` that ignores
108
+ // SIGTERM kept connect blocked past its deadline. 1 s after SIGTERM the group gets SIGKILL.
109
+ {
110
+ env,
111
+ extendEnv: false,
112
+ forceKillAfter: Duration.seconds(1),
113
+ stderr: 'pipe',
114
+ stdin: 'ignore',
115
+ stdout: 'pipe',
116
+ },
88
117
  ).pipe(
89
118
  spawner.spawn,
90
119
  Effect.flatMap((child) =>
@@ -114,7 +143,15 @@ export const readKvValue = (
114
143
  );
115
144
  }
116
145
 
117
- const parsed = JSON.parse(result.stdout) as { data?: { data?: Record<string, unknown> } };
146
+ // ⛔ A V8 SyntaxError quotes the offending input; this stdout may hold the secret itself.
147
+ let parsed: { data?: { data?: Record<string, unknown> } };
148
+ try {
149
+ parsed = JSON.parse(result.stdout) as typeof parsed;
150
+ } catch {
151
+ return yield* Effect.fail(
152
+ new Error(`bao kv get ${mount}/${key} returned output that is not JSON (not echoed).`),
153
+ );
154
+ }
118
155
  const data = parsed.data?.data;
119
156
  for (const field of fields) {
120
157
  const raw = data?.[field];
@@ -129,44 +166,13 @@ export const readKvValue = (
129
166
  });
130
167
 
131
168
  /**
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.
169
+ * ★ MEASURED AGAINST OPENBAO v2.6.2 (C1 fix, LAND red team, 2026-09-26 — isolated in-memory
170
+ * dev server): `bao kv get` on an unwritten KV-v2 key exits 2 with stderr exactly
171
+ * `No value found at <mount>/data/<key>`. `readKvValue`'s Error embeds that text. It is the
172
+ * only signal treated as "not written yet"; a permission denial or a transport failure is not.
144
173
  */
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({
153
- try: () => {
154
- const fd = openSync(path, 'wx', 0o600);
155
- try {
156
- writeFileSync(fd, raw);
157
- } finally {
158
- closeSync(fd);
159
- }
160
- },
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
- });
174
+ export const isVaultKeyAbsent = (error: unknown): boolean =>
175
+ error instanceof Error && /no value found at/i.test(error.message);
170
176
 
171
177
  /**
172
178
  * Mint one talosconfig file for `target`.
@@ -188,10 +194,10 @@ export const mintTalosconfig = (
188
194
 
189
195
  /**
190
196
  * 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.
197
+ * (K-talos-first-boot, 2026-09-26) — the file-shaped counterpart to {@link mintTalosconfig}.
198
+ * `Kubernetes.ClusterAdapter` `talos-openbao` does not use it: connect reads the bytes with
199
+ * {@link readKvValue} and keeps them in memory. This remains for a caller that must hand a
200
+ * path to a process.
195
201
  */
196
202
  export const mintKubeconfig = (
197
203
  target: TalosTarget,