@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.
- package/dist/talos/bao-env.d.ts +7 -0
- package/dist/talos/bao-env.d.ts.map +1 -0
- package/dist/talos/cluster-adapter-errors.d.ts +134 -0
- package/dist/talos/cluster-adapter-errors.d.ts.map +1 -0
- package/dist/talos/cluster-adapter.d.ts +116 -0
- package/dist/talos/cluster-adapter.d.ts.map +1 -0
- package/dist/talos/cluster-identity.d.ts +28 -0
- package/dist/talos/cluster-identity.d.ts.map +1 -0
- package/dist/talos/cluster-transport.d.ts +64 -0
- package/dist/talos/cluster-transport.d.ts.map +1 -0
- package/dist/talos/credentials-write.d.ts.map +1 -1
- package/dist/talos/credentials.d.ts +10 -19
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-apiserver.d.ts +9 -0
- package/dist/talos/fake-apiserver.d.ts.map +1 -0
- package/dist/talos/index.d.ts +7 -0
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +683 -170
- package/dist/talos/index.js.map +22 -10
- package/dist/talos/kubeconfig-attrs.d.ts +14 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -1
- package/dist/talos/kubeconfig-doc.d.ts +23 -0
- package/dist/talos/kubeconfig-doc.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +0 -12
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/literal-connection.d.ts +16 -0
- package/dist/talos/literal-connection.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +1 -1
- package/dist/talos/machine-config-read.d.ts.map +1 -1
- package/dist/talos/mint-temp-file.d.ts +20 -0
- package/dist/talos/mint-temp-file.d.ts.map +1 -0
- package/dist/talos/node-temp.harness.d.ts +2 -0
- package/dist/talos/node-temp.harness.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +15 -15
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-cluster-identity.d.ts +65 -0
- package/dist/talos/talos-cluster-identity.d.ts.map +1 -0
- package/dist/talos/talosctl-binary.d.ts +43 -0
- package/dist/talos/talosctl-binary.d.ts.map +1 -0
- package/dist/talos/talosctl.d.ts +29 -3
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/trust-boundary.d.ts +8 -0
- package/dist/talos/trust-boundary.d.ts.map +1 -0
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/talos/yaml-parse.d.ts +3 -0
- package/dist/talos/yaml-parse.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +2 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/talos-openbao-adapter.md +45 -0
- package/docs/upstream-conformance-bun.md +61 -24
- package/package.json +6 -2
- package/src/talos/bao-env.ts +16 -0
- package/src/talos/bao-vetting.test.ts +97 -0
- package/src/talos/cluster-adapter-deadline.test.ts +110 -0
- package/src/talos/cluster-adapter-errors.ts +193 -0
- package/src/talos/cluster-adapter-move.test.ts +131 -0
- package/src/talos/cluster-adapter-reconcile.test.ts +165 -0
- package/src/talos/cluster-adapter-refusals.test.ts +102 -0
- package/src/talos/cluster-adapter-rename.test.ts +87 -0
- package/src/talos/cluster-adapter-secrecy.test.ts +243 -0
- package/src/talos/cluster-adapter.fixtures.ts +125 -0
- package/src/talos/cluster-adapter.test.ts +240 -0
- package/src/talos/cluster-adapter.ts +250 -0
- package/src/talos/cluster-identity.ts +63 -0
- package/src/talos/cluster-transport.ts +110 -0
- package/src/talos/credentials-write.ts +27 -5
- package/src/talos/credentials.ts +51 -45
- package/src/talos/fake-apiserver.ts +55 -0
- package/src/talos/fake-process.ts +47 -1
- package/src/talos/index.ts +32 -0
- package/src/talos/kubeconfig-adapter.test.ts +37 -0
- package/src/talos/kubeconfig-attrs.ts +12 -28
- package/src/talos/kubeconfig-doc.ts +74 -0
- package/src/talos/kubeconfig.fixtures.ts +52 -0
- package/src/talos/kubeconfig.test.ts +6 -54
- package/src/talos/kubeconfig.ts +18 -34
- package/src/talos/literal-connection.ts +59 -0
- package/src/talos/mint-temp-file.test.ts +44 -0
- package/src/talos/mint-temp-file.ts +68 -0
- package/src/talos/node-connect.harness.ts +78 -0
- package/src/talos/node-connect.test.ts +27 -0
- package/src/talos/node-temp.harness.ts +40 -0
- package/src/talos/node-temp.test.ts +26 -0
- package/src/talos/talos-cluster-health-declare.test.ts +54 -0
- package/src/talos/talos-cluster-health-literal.test.ts +69 -0
- package/src/talos/talos-cluster-health.test.ts +104 -18
- package/src/talos/talos-cluster-health.ts +46 -14
- package/src/talos/talos-cluster-identity.test.ts +96 -0
- package/src/talos/talos-cluster-identity.ts +130 -0
- package/src/talos/talosctl-binary.ts +150 -0
- package/src/talos/talosctl-probe-kill.test.ts +56 -0
- package/src/talos/talosctl-vetting.test.ts +212 -0
- package/src/talos/talosctl.test.ts +163 -0
- package/src/talos/talosctl.ts +133 -15
- package/src/talos/trust-boundary.test.ts +17 -0
- package/src/talos/trust-boundary.ts +45 -0
- package/src/talos/values.ts +5 -12
- package/src/talos/yaml-parse.test.ts +41 -0
- package/src/talos/yaml-parse.ts +28 -0
- package/src/verify/fake-engine.ts +10 -0
- package/dist/talos/fake-process.d.ts +0 -56
- package/dist/talos/fake-process.d.ts.map +0 -1
- package/dist/talos/machine-config-fixtures.d.ts +0 -60
- package/dist/talos/machine-config-fixtures.d.ts.map +0 -1
- package/src/postgres/fixtures/pg-REL_18_6-acl.h.txt +0 -147
- package/src/postgres/fixtures/pg-REL_18_6-aclexplode-func.txt +0 -31
- package/src/postgres/fixtures/pg-REL_18_6-alter-default-privileges-synopsis.txt +0 -82
- package/src/postgres/fixtures/pg-REL_18_6-alter_role.sgml.txt +0 -44
- package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +0 -26
- package/src/postgres/fixtures/pg-REL_18_6-create_role.sgml.txt +0 -26
- package/src/postgres/fixtures/pg-REL_18_6-grant-synopsis.txt +0 -95
- package/src/postgres/fixtures/pg-REL_18_6-pg_attribute.h.txt +0 -24
- package/src/postgres/fixtures/pg-REL_18_6-pg_auth_members.h.txt +0 -62
- package/src/postgres/fixtures/pg-REL_18_6-pg_authid.h.txt +0 -70
- package/src/postgres/fixtures/pg-REL_18_6-pg_class.h.txt +0 -33
- package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +0 -67
- package/src/postgres/fixtures/pg-REL_18_6-pg_default_acl.h.txt +0 -81
- package/src/postgres/fixtures/pg-REL_18_6-pg_namespace.h.txt +0 -28
- package/src/postgres/fixtures/pg-REL_18_6-revoke-synopsis.txt +0 -124
- package/src/release/fixtures/openbao/checksums.txt +0 -83
- package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
- package/src/release/fixtures/openbao/darwin-assets.txt +0 -12
- package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +0 -109
- package/src/release/fixtures/victoria/darwin-arm64-assets.txt +0 -22
- package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +0 -2
- package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +0 -2
- package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +0 -2
- 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';
|
package/src/talos/index.ts
CHANGED
|
@@ -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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
13
|
-
import
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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: '',
|
package/src/talos/kubeconfig.ts
CHANGED
|
@@ -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
|
-
* -
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* (
|
|
30
|
-
*
|
|
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 {
|
|
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: () =>
|
|
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
|
+
});
|