@homeflare/alchemy 0.47.1 → 0.48.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 (128) 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 +115 -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 +669 -167
  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 +12 -15
  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 +44 -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 +192 -0
  58. package/src/talos/cluster-adapter-move.test.ts +129 -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 +249 -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.ts +56 -0
  81. package/src/talos/node-connect.harness.ts +78 -0
  82. package/src/talos/node-connect.test.ts +27 -0
  83. package/src/talos/node-temp.harness.ts +40 -0
  84. package/src/talos/node-temp.test.ts +26 -0
  85. package/src/talos/talos-cluster-health-declare.test.ts +54 -0
  86. package/src/talos/talos-cluster-health-literal.test.ts +69 -0
  87. package/src/talos/talos-cluster-health.test.ts +104 -18
  88. package/src/talos/talos-cluster-health.ts +46 -14
  89. package/src/talos/talos-cluster-identity.test.ts +96 -0
  90. package/src/talos/talos-cluster-identity.ts +130 -0
  91. package/src/talos/talosctl-binary.ts +150 -0
  92. package/src/talos/talosctl-vetting.test.ts +212 -0
  93. package/src/talos/talosctl.test.ts +110 -0
  94. package/src/talos/talosctl.ts +101 -15
  95. package/src/talos/trust-boundary.test.ts +17 -0
  96. package/src/talos/trust-boundary.ts +45 -0
  97. package/src/talos/values.ts +5 -12
  98. package/src/talos/yaml-parse.test.ts +41 -0
  99. package/src/talos/yaml-parse.ts +28 -0
  100. package/src/verify/fake-engine.ts +10 -0
  101. package/dist/talos/fake-process.d.ts +0 -56
  102. package/dist/talos/fake-process.d.ts.map +0 -1
  103. package/dist/talos/machine-config-fixtures.d.ts +0 -60
  104. package/dist/talos/machine-config-fixtures.d.ts.map +0 -1
  105. package/src/postgres/fixtures/pg-REL_18_6-acl.h.txt +0 -147
  106. package/src/postgres/fixtures/pg-REL_18_6-aclexplode-func.txt +0 -31
  107. package/src/postgres/fixtures/pg-REL_18_6-alter-default-privileges-synopsis.txt +0 -82
  108. package/src/postgres/fixtures/pg-REL_18_6-alter_role.sgml.txt +0 -44
  109. package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +0 -26
  110. package/src/postgres/fixtures/pg-REL_18_6-create_role.sgml.txt +0 -26
  111. package/src/postgres/fixtures/pg-REL_18_6-grant-synopsis.txt +0 -95
  112. package/src/postgres/fixtures/pg-REL_18_6-pg_attribute.h.txt +0 -24
  113. package/src/postgres/fixtures/pg-REL_18_6-pg_auth_members.h.txt +0 -62
  114. package/src/postgres/fixtures/pg-REL_18_6-pg_authid.h.txt +0 -70
  115. package/src/postgres/fixtures/pg-REL_18_6-pg_class.h.txt +0 -33
  116. package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +0 -67
  117. package/src/postgres/fixtures/pg-REL_18_6-pg_default_acl.h.txt +0 -81
  118. package/src/postgres/fixtures/pg-REL_18_6-pg_namespace.h.txt +0 -28
  119. package/src/postgres/fixtures/pg-REL_18_6-revoke-synopsis.txt +0 -124
  120. package/src/release/fixtures/openbao/checksums.txt +0 -83
  121. package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
  122. package/src/release/fixtures/openbao/darwin-assets.txt +0 -12
  123. package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +0 -109
  124. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +0 -22
  125. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +0 -2
  126. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +0 -2
  127. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +0 -2
  128. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +0 -8
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Fake apiserver for the `talos-openbao` tests: a `node:https` request spy (no socket, no DNS).
3
+ *
4
+ * ★ mock.module, not spyOn: alchemy imports `* as https`, a namespace a spy on the default export
5
+ * never reaches (measured 2026-10-06: the spy was bypassed and the test hit real DNS).
6
+ * Every request is recorded as `METHOD host path`. A GET of kube-system answers the uid the test
7
+ * assigned to that host (`uids`), so identity is exercised through the real adapter path; any
8
+ * other request answers 200 with an empty object. `restore()` puts the real module back.
9
+ */
10
+ import { EventEmitter } from 'node:events';
11
+ import * as httpsNamespace from 'node:https';
12
+ import { mock } from 'bun:test';
13
+
14
+ const realHttps = { ...httpsNamespace };
15
+
16
+ type FakeRequest = EventEmitter & { write: () => void; end: () => void };
17
+ type FakeResponse = EventEmitter & { statusCode?: number };
18
+
19
+ /**
20
+ * `uids` maps an apiserver hostname to the kube-system uid it reports; `hang` lists hostnames that
21
+ * accept the request and never answer.
22
+ */
23
+ export const fakeApiServer = (
24
+ uids: Readonly<Record<string, string>>,
25
+ hang: readonly string[] = [],
26
+ ) => {
27
+ const seen: string[] = [];
28
+ const fake = ((
29
+ options: { hostname: string; method: string; path: string },
30
+ callback: (response: FakeResponse) => void,
31
+ ) => {
32
+ const path = options.path.split('?')[0] ?? '';
33
+ seen.push(`${options.method} ${options.hostname}${path}`);
34
+ const request = new EventEmitter() as FakeRequest;
35
+ request.write = () => undefined;
36
+ request.end = () => {
37
+ if (hang.includes(options.hostname)) return;
38
+ const response = new EventEmitter() as FakeResponse;
39
+ response.statusCode = 200;
40
+ callback(response);
41
+ const isIdentity = options.method === 'GET' && path === '/api/v1/namespaces/kube-system';
42
+ const uid = isIdentity ? uids[options.hostname] : 'u1';
43
+ const metadata = uid === undefined ? {} : { uid };
44
+ response.emit('data', Buffer.from(JSON.stringify({ metadata })));
45
+ response.emit('end');
46
+ };
47
+ return request;
48
+ }) as never;
49
+ mock.module('node:https', () => ({
50
+ ...realHttps,
51
+ default: { ...realHttps, request: fake },
52
+ request: fake,
53
+ }));
54
+ return { restore: () => mock.module('node:https', () => realHttps), seen };
55
+ };
@@ -9,12 +9,21 @@
9
9
  * process and mutates process-global state for the test's duration, which is exactly the kind
10
10
  * of cross-test interference the house rules ask tests to avoid. `ChildProcessSpawner.make`
11
11
  * takes just a `spawn` function and derives everything else, so a fake `spawn` is the whole cost.
12
+ *
13
+ * ⚠️ ONE PROCESS-GLOBAL EXCEPTION (hunt round 8): importing this file permanently prepends a
14
+ * never-executed stub `talosctl` directory to `PATH` (`resolveDefault` walks PATH and vets what
15
+ * it finds, see the comment at that code) and defaults `BAO_ADDR`/`BAO_TOKEN` to placeholders
16
+ * (`readKvValue` refuses without them). Nothing real is spawned; the mutation outlives the file.
12
17
  */
18
+ import { chmodSync, realpathSync } from 'node:fs';
19
+ import { delimiter, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
13
21
  import * as Effect from 'effect/Effect';
14
22
  import * as Sink from 'effect/Sink';
15
23
  import * as Stream from 'effect/Stream';
16
24
  import type { Command } from 'effect/process/ChildProcess';
17
25
  import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
26
+ import { trustBoundaryForTests } from './trust-boundary.seam.ts';
18
27
 
19
28
  export type FakeResult = {
20
29
  readonly stdout?: string;
@@ -76,19 +85,56 @@ const stdinText = (stdin: unknown) =>
76
85
  * so that is the only shape this fake accepts — a `PipedCommand` is a test-authoring mistake, not
77
86
  * something to fake.
78
87
  */
88
+ // ★ THE DEFAULT `talosctl` IS RESOLVED ON PATH AND VETTED (talosctl.ts resolveDefault), so a test
89
+ // that never names a binary needs a real, trusted file there. A COMMITTED stub (mode 755, never
90
+ // executed: the fake spawner answers for it) goes first on PATH.
91
+ // ⚠️ NOT A TEMP DIR (measured 2026-10-06): the stub was a `mkdtemp` under os.tmpdir() removed by a
92
+ // `process.on('exit')` hook, and `bun test <one file>` leaked it (the house pre-push guard refused
93
+ // the push: `hf-fake-talosctl- 1`). `exit` does not reliably fire under bun test, and a module-level
94
+ // `afterAll` binds only to the FIRST file that imports this (cached) module, so no hook can own
95
+ // the lifetime. A fixture on disk has no lifetime to clean up.
96
+ const stubDir = fileURLToPath(new URL('./fixtures/bin', import.meta.url));
97
+ // ⚠️ A checkout at umask 002 (CT100's default, measured 2026-10-06) writes the fixture and its dirs
98
+ // 775, and the vetting rightly refuses group-writable. Git tracks only the executable bit, so
99
+ // normalising to 755 here changes no tracked content and weakens no check. The walk stops at the
100
+ // boundary (the stub dir), so the file and that dir are all it vets.
101
+ chmodSync(stubDir, 0o755);
102
+ chmodSync(join(stubDir, 'talosctl'), 0o755);
103
+ chmodSync(join(stubDir, 'bao'), 0o755);
104
+ export const STUB_TALOSCTL: string = realpathSync(join(stubDir, 'talosctl'));
105
+ export const STUB_BAO: string = realpathSync(join(stubDir, 'bao'));
106
+ trustBoundaryForTests(stubDir);
107
+ // (the boundary is the stub dir itself: it is vetted, nothing above it is)
108
+ process.env['PATH'] = `${realpathSync(stubDir)}${delimiter}${process.env['PATH'] ?? ''}`;
109
+
79
110
  export const fakeSpawner = (handler: FakeHandler, calls: FakeCall[] = []) =>
80
111
  ChildProcessSpawner.make((command: Command) =>
81
112
  Effect.gen(function* () {
82
113
  if (command._tag !== 'StandardCommand') {
83
114
  return yield* Effect.die(new Error('fake-process: only StandardCommand is supported'));
84
115
  }
116
+ // The vetting probe of the default binary is answered, not recorded, so call lists stay the
117
+ // caller's own calls; the recorded command is the bare name the callers asked for.
118
+ const isStub = command.command === STUB_TALOSCTL;
119
+ const isBaoStub = command.command === STUB_BAO;
120
+ if (isStub && command.args[0] === 'version' && command.args[1] === '--client') {
121
+ // ⚠️ A literal, not an import of TALOSCTL_PINNED_VERSION: node-connect.harness.ts loads this
122
+ // file under Node's strip-only TypeScript, which cannot load talosctl.ts. Drift fails loudly
123
+ // (every default-binary test then hits the version refusal).
124
+ return fakeHandle({ stdout: 'Client: Tag: v1.14.2' });
125
+ }
85
126
  const stdin = yield* stdinText(command.options.stdin);
86
127
  const call: FakeCall = {
87
128
  args: command.args,
88
- command: command.command,
129
+ command: isStub ? 'talosctl' : isBaoStub ? 'bao' : command.command,
89
130
  ...(stdin === undefined ? {} : { stdin }),
90
131
  };
91
132
  calls.push(call);
92
133
  return fakeHandle(handler(call));
93
134
  }),
94
135
  );
136
+
137
+ // ⚠️ `readKvValue` refuses to run without BAO_ADDR and BAO_TOKEN (no cached-login fallback), so
138
+ // every test that fakes `bao` needs both. Placeholders only; the fake spawner never reads them.
139
+ process.env['BAO_ADDR'] ??= 'https://bao.invalid';
140
+ process.env['BAO_TOKEN'] ??= 'fake-test-token';
@@ -18,9 +18,34 @@
18
18
  * (talos-bootstrap.ts's own header) and kubeconfig landing in OpenBao, written once at bring-up
19
19
  * (kubeconfig.ts's own header) — plus `Talos.ClusterHealth`'s error handling no longer conflates a
20
20
  * vault/transport failure with "cluster not healthy yet" (talos-cluster-health.ts's own header).
21
+ * ★ `talos-openbao` (K-A5) is how `Kubernetes.*` reaches the cluster: `TalosOpenBaoAdapter`,
22
+ * `talosOpenBaoConnection(<pinned uid literal>)` (or `cluster: health`) as the persisted
23
+ * `{ kind, uid }` connection — `Talos.Kubeconfig` and `Talos.ClusterIdentity` persist none — and
24
+ * `HF_TALOSCTL` to pin the talosctl binary.
21
25
  * ★ Anything unlisted is still reachable by path if you genuinely need it — that is a
22
26
  * deliberate, visible act rather than an accident of barrelling.
23
27
  */
28
+ export {
29
+ TalosClusterIdentityMismatch,
30
+ TalosClusterIdentityMissing,
31
+ TalosClusterIdentityTimeout,
32
+ TalosClusterIdentityUnreadable,
33
+ TalosClusterMoved,
34
+ TalosKubeconfigUnreadable,
35
+ TalosOpenBaoAdapter,
36
+ TalosOpenBaoAmbiguousUid,
37
+ TalosOpenBaoLegacyAuth,
38
+ TalosOpenBaoAuthKind,
39
+ TalosOpenBaoConnectTimeout,
40
+ type TalosOpenBaoCluster,
41
+ type TalosOpenBaoConfig,
42
+ TalosOpenBaoUnknownCluster,
43
+ TalosUidNotLiteral,
44
+ TalosVaultKeyMissing,
45
+ connectTalosOpenBao,
46
+ talosOpenBaoCluster,
47
+ talosOpenBaoConnection,
48
+ } from './cluster-adapter.ts';
24
49
  export type { TalosCredential, TalosTarget } from './credentials.ts';
25
50
  export {
26
51
  type KubeconfigAttributes,
@@ -35,6 +60,12 @@ export {
35
60
  TalosBootstrap,
36
61
  TalosBootstrapProvider,
37
62
  } from './talos-bootstrap.ts';
63
+ export {
64
+ type ClusterIdentityAttributes,
65
+ type ClusterIdentityProps,
66
+ TalosClusterIdentity,
67
+ TalosClusterIdentityProvider,
68
+ } from './talos-cluster-identity.ts';
38
69
  export {
39
70
  type ClusterHealthAttributes,
40
71
  type ClusterHealthProps,
@@ -47,3 +78,4 @@ export {
47
78
  TalosMachineConfig,
48
79
  TalosMachineConfigProvider,
49
80
  } from './talos-machine-config.ts';
81
+ export { DEFAULT_TALOSCTL_BINARY, TALOSCTL_BINARY_ENV } from './talosctl.ts';
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `Talos.Kubeconfig` diff against a row saved before the `talos-openbao` adapter existed.
3
+ * Offline: fake-process.ts fakes `bao`/`talosctl`.
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import { writeFileSync } from 'node:fs';
7
+ import { describe, it } from 'node:test';
8
+ import type { FakeCall } from './fake-process.ts';
9
+ import { fixtureKubeconfig, props, run } from './kubeconfig.fixtures.ts';
10
+ import {
11
+ type KubeconfigAttributes,
12
+ diffKubeconfig as diff,
13
+ reconcileKubeconfig as reconcile,
14
+ } from './kubeconfig.ts';
15
+
16
+ describe('diff — legacy rows', () => {
17
+ it('plans update for a row saved with the dead placeholder connection', async () => {
18
+ const output = await run(reconcile(props(), undefined), (call: FakeCall) => {
19
+ if (call.command === 'bao' && call.args[1] === 'get') {
20
+ return { stdout: JSON.stringify({ data: { data: { talosconfig: 'x' } } }) };
21
+ }
22
+ if (call.command === 'bao' && call.args[1] === 'put') return {};
23
+ writeFileSync(call.args[1] ?? '', fixtureKubeconfig('old'));
24
+ return {};
25
+ });
26
+ const old = {
27
+ ...(output as KubeconfigAttributes),
28
+ connection: { auth: { context: 'admin@hf-c1', kind: 'kubeconfig' } },
29
+ } as unknown as KubeconfigAttributes;
30
+ const result = await run(diff(props(), old), (call: FakeCall) =>
31
+ call.args[1] === 'get'
32
+ ? { stdout: JSON.stringify({ data: { data: { config: fixtureKubeconfig('old') } } }) }
33
+ : {},
34
+ );
35
+ assert.equal(result?.action, 'update');
36
+ });
37
+ });
@@ -1,11 +1,17 @@
1
1
  /**
2
- * `Talos.Kubeconfig`'s attribute builder and its `Kubernetes.Connection` — split out of
3
- * kubeconfig.ts (LAND, 2026-09-26, C1/I2 fixes) once fixing those findings pushed the file over the
4
- * 250-line cap, the same reason machine-config-read.ts/machine-config-poll.ts/talos-errors.ts were
5
- * split out of talos-machine-config.ts before it. Only `import type` comes back from kubeconfig.ts,
6
- * so there is no runtime cycle between the two files.
2
+ * `Talos.Kubeconfig`'s attribute builder — split out of kubeconfig.ts (LAND, 2026-09-26, C1/I2
3
+ * fixes) once fixing those findings pushed the file over the 250-line cap. Only `import type`
4
+ * comes back from kubeconfig.ts, so there is no runtime cycle.
5
+ *
6
+ * ⛔ NO `connection` HERE. A connection names the PHYSICAL cluster (`{ kind, uid }`,
7
+ * cluster-adapter.ts's identity rule) and the uid is only knowable from the cluster, which does
8
+ * not exist yet when this resource writes the vault key. Workloads get their connection from the
9
+ * operator's pinned literal (`talosOpenBaoConnection(uid)`) or `Talos.ClusterHealth`'s
10
+ * pass-through; `Talos.ClusterIdentity` publishes `{ uid }` only; a name-only connection
11
+ * (`cluster` present) is refused by the adapter as legacy. Neither the
12
+ * stock `kubeconfig` kind (an absent path falls back to `$KUBECONFIG`) nor `client-cert` (PEM on
13
+ * every workload's attributes, `Connection.ts` v2.0.0-beta.79 lines 12-14) is ever used.
7
14
  */
8
- import type { Connection } from 'alchemy/Kubernetes/Connection';
9
15
  import type { KubeconfigAttributes, KubeconfigProps } from './kubeconfig.ts';
10
16
  import { kubeconfigMetadata, sha256 } from './values.ts';
11
17
 
@@ -16,27 +22,6 @@ const generation = (meta: {
16
22
  context: string;
17
23
  }) => sha256(`${meta.context}\n${meta.endpoint}\n${meta.caFingerprint}\n${meta.clientFingerprint}`);
18
24
 
19
- /**
20
- * ⛔ I2 FIX (LAND red team, 2026-09-26) — NEVER LEAVE `path` UNDEFINED. An undefined `path` here
21
- * made the stock `Kubernetes.KubeConfigAdapter` fall back to `$KUBECONFIG`/`~/.kube/config`
22
- * (alchemy `Kubernetes/internal/kubeconfig.ts`'s `resolveKubeConfigPath`, wired in
23
- * `BuiltinAdapters.ts`) — exactly the un-vaulted, possibly-stale-context exposure this whole
24
- * family exists to remove. A consumer wiring `Kubernetes.*` to `kube.connection` (this
25
- * attribute's own doc invites exactly that) would silently connect through whatever
26
- * `admin@<cluster>` context happens to be on the operator's machine. `Kubernetes.ClusterAdapter`
27
- * is not yet wired to `mintKubeconfig` (kubeconfig.ts's own header) — no real path exists for
28
- * this connection to use — so it must fail LOUDLY instead of silently succeeding against a
29
- * stranger's cluster. A path that can never resolve does that: `resolveKubeContext` fails at
30
- * `fs.readFileString` naming this exact sentinel, instead of reading whatever real file happens
31
- * to be on `$KUBECONFIG`.
32
- */
33
- const UNWIRED_KUBECONFIG_PATH =
34
- '/talos-first-boot-unwired/wire-Kubernetes.ClusterAdapter-to-mintKubeconfig';
35
-
36
- const toConnection = (props: KubeconfigProps): Connection => ({
37
- auth: { context: props.context, kind: 'kubeconfig', path: UNWIRED_KUBECONFIG_PATH },
38
- });
39
-
40
25
  /** Parse `raw` kubeconfig YAML into public, persistable attributes, or `undefined` if it fails to. */
41
26
  export const buildAttrs = (
42
27
  raw: string,
@@ -47,7 +32,6 @@ export const buildAttrs = (
47
32
  return {
48
33
  certificateAuthorityFingerprint: meta.caFingerprint,
49
34
  clientCertificateFingerprint: meta.clientFingerprint,
50
- connection: toConnection(props),
51
35
  context: props.context,
52
36
  credentialGeneration: generation({ ...meta, context: props.context }),
53
37
  endpoint: meta.endpoint,
@@ -0,0 +1,74 @@
1
+ /**
2
+ * In-memory kubeconfig parse for `talos-openbao` connect.
3
+ *
4
+ * ⛔ THE RESULT HOLDS PEM. Callers may put it on a `ClusterTransport` for one request.
5
+ * They must not persist it, log it, or copy it onto a `Connection`. Alchemy stores
6
+ * connection attributes in plaintext (`credentials.ts`'s own header).
7
+ * ★ YAML IS `yaml-parse.ts` (runtime-neutral: Bun.YAML does not exist under Node, where the
8
+ * published dist runs). Nothing here touches a filesystem or OpenBao — the caller has already
9
+ * read the bytes.
10
+ *
11
+ * Walked against alchemy 2.0.0-beta.79 `Kubernetes/internal/client.ts`: `certificateAuthorityData`
12
+ * stays base64 (the client decodes it) and `clientCert` is PEM. The document shape is the one
13
+ * `values.ts` parses from the Talos v1.13 kubeconfig reference. Not measured on a live cluster.
14
+ */
15
+ import type { ClusterTransport } from 'alchemy/Kubernetes/ClusterAdapter';
16
+ import { parseYaml } from './yaml-parse.ts';
17
+
18
+ type KubeconfigDoc = {
19
+ clusters?: {
20
+ cluster?: { server?: string; 'certificate-authority-data'?: string };
21
+ name?: string;
22
+ }[];
23
+ contexts?: { context?: { cluster?: string; user?: string }; name?: string }[];
24
+ users?: {
25
+ name?: string;
26
+ user?: { 'client-certificate-data'?: string; 'client-key-data'?: string };
27
+ }[];
28
+ };
29
+
30
+ export type KubeconfigMaterial = Pick<
31
+ ClusterTransport,
32
+ 'certificateAuthorityData' | 'clientCert' | 'endpoint'
33
+ >;
34
+
35
+ /** Base64 kubeconfig field → PEM, or `undefined` when it is not a PEM payload. */
36
+ const pem = (data: string | undefined): string | undefined => {
37
+ if (data === undefined || data.trim() === '') return undefined;
38
+ const decoded = Buffer.from(data, 'base64').toString('utf8');
39
+ return decoded.includes('BEGIN ') ? decoded : undefined;
40
+ };
41
+
42
+ /**
43
+ * Resolve `contextName` to endpoint, base64 CA, and PEM client cert/key.
44
+ * `undefined` for any shape that is not that context's admin kubeconfig — the caller
45
+ * fails closed without embedding the document in the error.
46
+ */
47
+ export const kubeconfigTransport = (
48
+ yamlText: string,
49
+ contextName: string,
50
+ ): KubeconfigMaterial | undefined => {
51
+ const parsed = parseYaml(yamlText);
52
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
53
+ const doc = parsed as KubeconfigDoc;
54
+ const ctx = doc.contexts?.find((row) => row.name === contextName)?.context;
55
+ const clusterName = ctx?.cluster;
56
+ const userName = ctx?.user;
57
+ if (clusterName === undefined || userName === undefined) return undefined;
58
+ const cluster = doc.clusters?.find((row) => row.name === clusterName)?.cluster;
59
+ const user = doc.users?.find((row) => row.name === userName)?.user;
60
+ const endpoint = cluster?.server;
61
+ const certificateAuthorityData = cluster?.['certificate-authority-data'];
62
+ const certificate = pem(user?.['client-certificate-data']);
63
+ const key = pem(user?.['client-key-data']);
64
+ if (
65
+ endpoint === undefined ||
66
+ certificateAuthorityData === undefined ||
67
+ pem(certificateAuthorityData) === undefined ||
68
+ certificate === undefined ||
69
+ key === undefined
70
+ ) {
71
+ return undefined;
72
+ }
73
+ return { certificateAuthorityData, clientCert: { certificate, key }, endpoint };
74
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared fixtures for the `Talos.Kubeconfig` tests: a fixture kubeconfig and the fake-spawner
3
+ * runner.
4
+ *
5
+ * ⚠️ FIXTURES ONLY — see values.test.ts's own header for why the base64 fields are built from
6
+ * plain words at run time rather than written out literally.
7
+ */
8
+ import * as Effect from 'effect/Effect';
9
+ import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
10
+ import { type FakeCall, fakeSpawner } from './fake-process.ts';
11
+ import type { KubeconfigProps } from './kubeconfig.ts';
12
+
13
+ const b64 = (words: string) => Buffer.from(words).toString('base64');
14
+
15
+ export const fixtureKubeconfig = (marker: string) => `
16
+ apiVersion: v1
17
+ kind: Config
18
+ clusters:
19
+ - name: hf-c1
20
+ cluster:
21
+ server: https://192.0.2.50:6443
22
+ certificate-authority-data: ${b64(`not a ca ${marker}`)}
23
+ contexts:
24
+ - name: admin@hf-c1
25
+ context: { cluster: hf-c1, user: admin@hf-c1 }
26
+ users:
27
+ - name: admin@hf-c1
28
+ user:
29
+ client-certificate-data: ${b64(`not a certificate ${marker}`)}
30
+ client-key-data: ${b64(`not a key ${marker}`)}
31
+ current-context: admin@hf-c1
32
+ `;
33
+
34
+ const TARGET = { cluster: 'c1', mount: 'talos-c1' };
35
+ export const props = (): KubeconfigProps => ({
36
+ context: 'admin@hf-c1',
37
+ node: '198.51.100.10',
38
+ target: TARGET,
39
+ });
40
+
41
+ export const run = <A, E>(
42
+ effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
43
+ handler: (c: FakeCall) => { stdout?: string; stderr?: string; exitCode?: number },
44
+ calls: FakeCall[] = [],
45
+ ) =>
46
+ Effect.runPromise(
47
+ Effect.provideService(
48
+ effect,
49
+ ChildProcessSpawner.ChildProcessSpawner,
50
+ fakeSpawner(handler, calls),
51
+ ),
52
+ );
@@ -9,58 +9,15 @@
9
9
  import assert from 'node:assert/strict';
10
10
  import { existsSync, writeFileSync } from 'node:fs';
11
11
  import { describe, it } from 'node:test';
12
- import * as Effect from 'effect/Effect';
13
- import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
14
- import { type FakeCall, fakeSpawner } from './fake-process.ts';
12
+ import type { FakeCall } from './fake-process.ts';
13
+ import { fixtureKubeconfig, props, run } from './kubeconfig.fixtures.ts';
15
14
  import {
16
15
  type KubeconfigAttributes,
17
- type KubeconfigProps,
18
16
  diffKubeconfig as diff,
19
17
  readKubeconfig as read,
20
18
  reconcileKubeconfig as reconcile,
21
19
  } from './kubeconfig.ts';
22
20
 
23
- const b64 = (words: string) => Buffer.from(words).toString('base64');
24
-
25
- const fixtureKubeconfig = (marker: string) => `
26
- apiVersion: v1
27
- kind: Config
28
- clusters:
29
- - name: hf-c1
30
- cluster:
31
- server: https://192.0.2.50:6443
32
- certificate-authority-data: ${b64(`not a ca ${marker}`)}
33
- contexts:
34
- - name: admin@hf-c1
35
- context: { cluster: hf-c1, user: admin@hf-c1 }
36
- users:
37
- - name: admin@hf-c1
38
- user:
39
- client-certificate-data: ${b64(`not a certificate ${marker}`)}
40
- client-key-data: ${b64(`not a key ${marker}`)}
41
- current-context: admin@hf-c1
42
- `;
43
-
44
- const TARGET = { cluster: 'c1', mount: 'talos-c1' };
45
- const props = (): KubeconfigProps => ({
46
- context: 'admin@hf-c1',
47
- node: '198.51.100.10',
48
- target: TARGET,
49
- });
50
-
51
- const run = <A, E>(
52
- effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
53
- handler: (c: FakeCall) => { stdout?: string; stderr?: string; exitCode?: number },
54
- calls: FakeCall[] = [],
55
- ) =>
56
- Effect.runPromise(
57
- Effect.provideService(
58
- effect,
59
- ChildProcessSpawner.ChildProcessSpawner,
60
- fakeSpawner(handler, calls),
61
- ),
62
- );
63
-
64
21
  describe('reconcile — CREATE writes talosctl kubeconfig output into the vault, never a host path', () => {
65
22
  it('mints the temp file, writes its content to vault via stdin, cleans up the temp file', async () => {
66
23
  const calls: FakeCall[] = [];
@@ -85,13 +42,10 @@ describe('reconcile — CREATE writes talosctl kubeconfig output into the vault,
85
42
 
86
43
  assert.equal(result.endpoint, 'https://192.0.2.50:6443');
87
44
  assert.equal(result.context, 'admin@hf-c1');
88
- assert.equal(result.connection.auth.kind, 'kubeconfig');
89
- // ⛔ I2 (LAND red team): NEVER undefined — an undefined `path` here would let the stock
90
- // KubeConfigAdapter fall back to $KUBECONFIG/~/.kube/config. It must be a path that can never
91
- // resolve, so a consumer that isn't yet wired to mintKubeconfig fails loudly instead of quietly
92
- // reaching the operator's own cluster.
93
- const authPath = (result.connection.auth as { path?: string }).path;
94
- assert.ok(typeof authPath === 'string' && authPath.length > 0);
45
+ assert.equal('connection' in result, false, 'the identity resource owns the connection');
46
+ assert.equal(JSON.stringify(result).includes('talos-first-boot-unwired'), false);
47
+ assert.equal(JSON.stringify(result).includes('BEGIN CERTIFICATE'), false);
48
+ assert.equal(JSON.stringify(result).includes('PRIVATE KEY'), false);
95
49
 
96
50
  const put = calls.find((c) => c.command === 'bao' && c.args[1] === 'put');
97
51
  assert.ok(put);
@@ -110,7 +64,6 @@ describe('reconcile — write-once: a second reconcile never re-runs talosctl ku
110
64
  const priorOutput: KubeconfigAttributes = {
111
65
  certificateAuthorityFingerprint: 'x',
112
66
  clientCertificateFingerprint: 'x',
113
- connection: { auth: { context: 'admin@hf-c1', kind: 'kubeconfig' } },
114
67
  context: 'admin@hf-c1',
115
68
  credentialGeneration: 'x',
116
69
  endpoint: 'https://192.0.2.50:6443',
@@ -205,7 +158,6 @@ describe('reconcile — belt-and-suspenders: a defined-but-empty output is treat
205
158
  const staleEmptyOutput: KubeconfigAttributes = {
206
159
  certificateAuthorityFingerprint: '',
207
160
  clientCertificateFingerprint: '',
208
- connection: { auth: { context: 'admin@hf-c1', kind: 'kubeconfig' } },
209
161
  context: 'admin@hf-c1',
210
162
  credentialGeneration: '',
211
163
  endpoint: '',
@@ -23,23 +23,25 @@
23
23
  * (docs/plans/2026-09-26-talos-secrets-flow.md, O1). Once `output` is defined, reconcile only
24
24
  * reads the vault copy back to confirm it — the same "once" shape `talos-bootstrap.ts` uses
25
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.
26
+ * - NO `connection` is persisted here: a connection names the physical cluster by its
27
+ * kube-system uid, which only `Talos.ClusterIdentity` can read (cluster-adapter.ts, the
28
+ * identity rule). The adapter reads the vault at connect time. ⛔ Never the stock
29
+ * `kubeconfig` kind (absent path falls back to `$KUBECONFIG`) nor `client-cert` (PEM on
30
+ * every workload's attributes, alchemy `Connection.ts`).
34
31
  */
32
+ import { readFile } from 'node:fs/promises';
35
33
  import { chmodSync } from 'node:fs';
36
34
  import { Resource } from 'alchemy';
37
35
  import { isResolved } from 'alchemy/Diff';
38
36
  import type { Input } from 'alchemy/Input';
39
- import type { Connection } from 'alchemy/Kubernetes/Connection';
40
37
  import * as Provider from 'alchemy/Provider';
41
38
  import * as Effect from 'effect/Effect';
42
- import { DEFAULT_KUBECONFIG_KEY, mintTalosconfig, readKvValue } from './credentials.ts';
39
+ import {
40
+ DEFAULT_KUBECONFIG_KEY,
41
+ isVaultKeyAbsent,
42
+ mintTalosconfig,
43
+ readKvValue,
44
+ } from './credentials.ts';
43
45
  import { reservedTempPath, writeKvValue } from './credentials-write.ts';
44
46
  import { buildAttrs } from './kubeconfig-attrs.ts';
45
47
  import type { TalosRequirements, WithTarget } from './resource.ts';
@@ -62,17 +64,6 @@ export interface KubeconfigAttributes {
62
64
  clientCertificateFingerprint: string;
63
65
  /** Digest of endpoint+context+fingerprints — detects credential rotation without storing PEM. */
64
66
  credentialGeneration: string;
65
- /**
66
- * Serializable `Kubernetes.Connection` for downstream workloads.
67
- *
68
- * ★ auth.kind `kubeconfig` is the stock ClusterAdapter — provider-roadmap.md names this seam.
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.
74
- */
75
- connection: Connection;
76
67
  }
77
68
 
78
69
  export interface TalosKubeconfig extends Resource<
@@ -85,18 +76,7 @@ export interface TalosKubeconfig extends Resource<
85
76
 
86
77
  export const TalosKubeconfig = Resource<TalosKubeconfig>('Talos.Kubeconfig');
87
78
 
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);
99
-
79
+ /** Absence is {@link isVaultKeyAbsent} — measured OpenBao v2.6.2 text, not every bao failure. */
100
80
  const readVaultMeta = (props: KubeconfigProps, key: string) =>
101
81
  readKvValue(props.target.mount, key, ['kubeconfig', 'config']).pipe(
102
82
  Effect.catchIf(isVaultKeyAbsent, () => Effect.succeed(undefined)),
@@ -143,6 +123,10 @@ export const diffKubeconfig = (
143
123
  live !== undefined &&
144
124
  live.credentialGeneration === output.credentialGeneration &&
145
125
  live.context === output.context &&
126
+ // ⚠️ A row saved by an earlier build still carries a `connection` (a name-only or dead
127
+ // placeholder one). It must update so the stale connection leaves state: a workload wired
128
+ // to this resource would otherwise keep an auth block that names no physical cluster.
129
+ !('connection' in output) &&
146
130
  live.endpoint === output.endpoint
147
131
  ) {
148
132
  return { action: 'noop' } as const;
@@ -195,7 +179,7 @@ export const reconcileKubeconfig = (
195
179
  );
196
180
  const raw = yield* Effect.tryPromise({
197
181
  catch: (cause) => new Error(`${outPath}: reading generated kubeconfig: ${String(cause)}`),
198
- try: () => Bun.file(outPath).text(),
182
+ try: () => readFile(outPath, 'utf8'),
199
183
  });
200
184
  if (raw.trim() === '') {
201
185
  return yield* Effect.die(
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Runtime refusal of a `talos-openbao` connection that is not a plain literal.
3
+ *
4
+ * ⛔ A RESOURCE THAT TAKES A `connection` PROP MUST CALL THIS IN `diff`, WHERE THE PROP IS STILL
5
+ * AN `Input`. By `read`/`reconcile` the engine has resolved an Output to its current string, so
6
+ * the same check there can no longer tell a literal from an Output: the refusal only works
7
+ * before resolution. An Output in the connection is unresolved at plan time, upstream plans an
8
+ * UPDATE for every workload on it, and reconcile replays the old cluster's deletes on the new
9
+ * one (`talosOpenBaoConnection` in cluster-adapter.ts).
10
+ */
11
+ import { hasUnresolvedInputs } from 'alchemy/Diff';
12
+ import * as Effect from 'effect/Effect';
13
+ import { TalosUidNotLiteral } from './cluster-adapter-errors.ts';
14
+
15
+ /**
16
+ * The typed refusal unless `connection` is `{ auth: { kind, uid: literal } }`, else `undefined`.
17
+ * ★ A value, never a throw: a throw inside `Effect.gen` would become a defect, not a failure.
18
+ */
19
+ export const literalConnectionRefusal = (connection: unknown): TalosUidNotLiteral | undefined => {
20
+ const auth = (connection as { auth?: { kind?: unknown; uid?: unknown } } | undefined)?.auth;
21
+ // ⛔ A stock `kubeconfig` connection has no uid and would pass the checks below by failing the
22
+ // `uid` test as "not a string" — but it must be refused here, at declaration/diff time, because
23
+ // connect would only refuse it later, after the engine has resolved it (and its empty path has
24
+ // already fallen back to `$KUBECONFIG`). Only `talos-openbao` carries a literal uid.
25
+ return hasUnresolvedInputs(connection) ||
26
+ auth?.kind !== 'talos-openbao' ||
27
+ typeof auth?.uid !== 'string' ||
28
+ auth.uid === ''
29
+ ? new TalosUidNotLiteral({})
30
+ : undefined;
31
+ };
32
+
33
+ /**
34
+ * ⛔ THE `diff` GUARD ALONE IS NOT ENOUGH (round-4 review): alchemy skips `diff` for a FRESH
35
+ * resource whose inputs are unresolved, and reconcile then copies the resolved string downstream
36
+ * — an Output uid slips through on exactly the first deploy. So the same refusal runs when the
37
+ * resource is DECLARED, before any input resolution, by wrapping its constructor.
38
+ * ★ A Proxy over the class (not a new function) keeps `.Provider`, `.Self`, `.ref` and the type.
39
+ * Plain props fail the declaring Effect; a props Effect fails once it yields.
40
+ */
41
+ export const withLiteralConnection = <C extends (...args: never[]) => unknown>(resource: C): C =>
42
+ new Proxy(resource, {
43
+ apply: (target, thisArg, args: unknown[]) => {
44
+ const refusal = (given: unknown) =>
45
+ literalConnectionRefusal((given as { connection?: unknown } | undefined)?.connection);
46
+ const props = args[1];
47
+ if (Effect.isEffect(props)) {
48
+ const checked = props.pipe(
49
+ Effect.flatMap((resolved) => {
50
+ const refused = refusal(resolved);
51
+ return refused === undefined ? Effect.succeed(resolved) : Effect.fail(refused);
52
+ }),
53
+ );
54
+ return Reflect.apply(target, thisArg, [args[0], checked, ...args.slice(2)]);
55
+ }
56
+ const refused = refusal(props);
57
+ return refused === undefined ? Reflect.apply(target, thisArg, args) : Effect.fail(refused);
58
+ },
59
+ });