@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,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HF_TALOSCTL` vetting: path (absolute, lstat, owner, parent directory, mode), exact version, and
|
|
3
|
+
* the minimal child env. Fake spawner only — the named file is never executed.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
chmodSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
symlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { afterAll, expect, test } from 'bun:test';
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
19
|
+
import { type FakeHandler, fakeSpawner } from './fake-process.ts';
|
|
20
|
+
import { trustBoundaryForTests } from './trust-boundary.seam.ts';
|
|
21
|
+
import { TalosBinaryRefused, TalosError, talosctl } from './talosctl.ts';
|
|
22
|
+
|
|
23
|
+
const run = (binary: string, handler: FakeHandler = () => ({}), envs: unknown[] = []) =>
|
|
24
|
+
Effect.runPromise(
|
|
25
|
+
Effect.provideService(
|
|
26
|
+
talosctl(['version'], { binary, talosconfigPath: 'unused.yaml' }),
|
|
27
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
28
|
+
ChildProcessSpawner.make((cmd) => {
|
|
29
|
+
if (cmd._tag === 'StandardCommand') envs.push(cmd.options);
|
|
30
|
+
return fakeSpawner(handler).spawn(cmd);
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
).catch((e: unknown) => String(e));
|
|
34
|
+
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), 'hf-talosctl-vet-'));
|
|
36
|
+
chmodSync(dir, 0o755);
|
|
37
|
+
trustBoundaryForTests(dir);
|
|
38
|
+
afterAll(() => rmSync(dir, { force: true, recursive: true }));
|
|
39
|
+
const trusted = (name: string) => {
|
|
40
|
+
const path = join(dir, name);
|
|
41
|
+
writeFileSync(path, '#!/bin/sh\n', { mode: 0o755 });
|
|
42
|
+
chmodSync(path, 0o755);
|
|
43
|
+
return path;
|
|
44
|
+
};
|
|
45
|
+
const pinned =
|
|
46
|
+
(tag: string): FakeHandler =>
|
|
47
|
+
() => ({ stdout: `Client: Tag: ${tag}` });
|
|
48
|
+
|
|
49
|
+
test('relative, missing, group/world-writable and symlinked overrides are refused', async () => {
|
|
50
|
+
expect(await run('relative/talosctl')).toContain('absolute');
|
|
51
|
+
expect(await run(join(dir, 'missing'))).toContain('not a readable file');
|
|
52
|
+
const bin = trusted('writable');
|
|
53
|
+
chmodSync(bin, 0o777);
|
|
54
|
+
expect(await run(bin)).toContain('group- or world-writable');
|
|
55
|
+
const link = join(dir, 'link');
|
|
56
|
+
symlinkSync(trusted('target'), link);
|
|
57
|
+
expect(await run(link)).toContain('symlink');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('an override in a group- or world-writable directory is refused', async () => {
|
|
61
|
+
const open = join(dir, 'open');
|
|
62
|
+
mkdirSync(open);
|
|
63
|
+
const bin = join(open, 'talosctl');
|
|
64
|
+
writeFileSync(bin, '#!/bin/sh\n', { mode: 0o755 });
|
|
65
|
+
chmodSync(open, 0o777);
|
|
66
|
+
expect(await run(bin)).toContain('writable directory');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a writable ancestor of the override is refused (fails on 37d835a: only the parent was checked)', async () => {
|
|
70
|
+
const outer = join(dir, 'outer');
|
|
71
|
+
mkdirSync(join(outer, 'inner'), { recursive: true });
|
|
72
|
+
const bin = join(outer, 'inner', 'talosctl');
|
|
73
|
+
writeFileSync(bin, '#!/bin/sh\n', { mode: 0o755 });
|
|
74
|
+
chmodSync(join(outer, 'inner'), 0o755);
|
|
75
|
+
chmodSync(outer, 0o777);
|
|
76
|
+
expect(await run(bin, pinned('v1.14.2'))).toContain('writable directory');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the walk stops at a registered boundary but not above an unregistered one (guards the trust-boundary.seam wiring: if the seam is missing or the pull fails, the jailed shape is refused too)', async () => {
|
|
80
|
+
// Two identical shapes: `jailed/inner` is registered as a boundary, `free/inner` is not. The
|
|
81
|
+
// wrapper above each is group/world-writable, so only the registered one can pass: the
|
|
82
|
+
// boundary directory itself is still vetted (755), and its wrapper is never reached.
|
|
83
|
+
const jailed = join(dir, 'jailed');
|
|
84
|
+
const free = join(dir, 'free');
|
|
85
|
+
for (const wrapper of [jailed, free]) {
|
|
86
|
+
mkdirSync(join(wrapper, 'inner', 'bin'), { recursive: true });
|
|
87
|
+
// ⚠️ The BOUNDARY DIRECTORY is vetted too, so it must be 755 — not the umask default. Measured
|
|
88
|
+
// 2026-10-06 at umask 002 (CT100's default): `mkdirSync(…, { recursive: true })` creates the
|
|
89
|
+
// intermediate `inner` group-writable (775), and the walk rightly refused the boundary itself
|
|
90
|
+
// before ever reaching the 777 wrapper the test means to probe.
|
|
91
|
+
chmodSync(join(wrapper, 'inner'), 0o755);
|
|
92
|
+
chmodSync(join(wrapper, 'inner', 'bin'), 0o755);
|
|
93
|
+
writeFileSync(join(wrapper, 'inner', 'bin', 'talosctl'), '#!/bin/sh\n', { mode: 0o755 });
|
|
94
|
+
chmodSync(join(wrapper, 'inner', 'bin', 'talosctl'), 0o755);
|
|
95
|
+
}
|
|
96
|
+
trustBoundaryForTests(join(jailed, 'inner'));
|
|
97
|
+
chmodSync(jailed, 0o777);
|
|
98
|
+
chmodSync(free, 0o777);
|
|
99
|
+
expect(await run(join(jailed, 'inner', 'bin', 'talosctl'), pinned('v1.14.2'))).toBe(
|
|
100
|
+
'Client: Tag: v1.14.2',
|
|
101
|
+
);
|
|
102
|
+
expect(await run(join(free, 'inner', 'bin', 'talosctl'), pinned('v1.14.2'))).toContain(
|
|
103
|
+
'writable directory',
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('an ancestor owned by another user is refused (fails on 37d835a: parent owner never read)', async () => {
|
|
108
|
+
const owner = join(dir, 'foreign');
|
|
109
|
+
mkdirSync(owner);
|
|
110
|
+
const bin = join(owner, 'talosctl');
|
|
111
|
+
writeFileSync(bin, '#!/bin/sh\n', { mode: 0o755 });
|
|
112
|
+
chmodSync(owner, 0o755);
|
|
113
|
+
expect(await run(bin, pinned('v1.14.2'))).toBe('Client: Tag: v1.14.2');
|
|
114
|
+
const real = process.getuid;
|
|
115
|
+
// Everything here is ours; pretending to be another uid makes every directory foreign.
|
|
116
|
+
Object.defineProperty(process, 'getuid', { configurable: true, value: () => 4242 });
|
|
117
|
+
try {
|
|
118
|
+
expect(await run(bin, pinned('v1.14.2'))).toContain('directory owned by another user');
|
|
119
|
+
} finally {
|
|
120
|
+
Object.defineProperty(process, 'getuid', { configurable: true, value: real });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a non-executable override is refused', async () => {
|
|
125
|
+
const bin = join(dir, 'noexec');
|
|
126
|
+
writeFileSync(bin, '#!/bin/sh\n', { mode: 0o644 });
|
|
127
|
+
chmodSync(bin, 0o644);
|
|
128
|
+
expect(await run(bin, pinned('v1.14.2'))).toContain('not executable');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('both launches use the canonical path even if an ancestor symlink is swapped (fails on ede1f6f)', async () => {
|
|
132
|
+
const real = join(dir, 'real-bin');
|
|
133
|
+
const evil = join(dir, 'evil-bin');
|
|
134
|
+
for (const d of [real, evil]) {
|
|
135
|
+
mkdirSync(d);
|
|
136
|
+
chmodSync(d, 0o755);
|
|
137
|
+
writeFileSync(join(d, 'talosctl'), '#!/bin/sh\n', { mode: 0o755 });
|
|
138
|
+
chmodSync(join(d, 'talosctl'), 0o755);
|
|
139
|
+
}
|
|
140
|
+
const via = join(dir, 'via');
|
|
141
|
+
symlinkSync(real, via);
|
|
142
|
+
const launched: string[] = [];
|
|
143
|
+
const out = await Effect.runPromise(
|
|
144
|
+
Effect.provideService(
|
|
145
|
+
talosctl(['version'], { binary: join(via, 'talosctl'), talosconfigPath: 'unused.yaml' }),
|
|
146
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
147
|
+
ChildProcessSpawner.make((cmd) => {
|
|
148
|
+
if (cmd._tag === 'StandardCommand') launched.push(cmd.command);
|
|
149
|
+
// The attacker repoints the link right after the version probe.
|
|
150
|
+
rmSync(via);
|
|
151
|
+
symlinkSync(evil, via);
|
|
152
|
+
return fakeSpawner(pinned('v1.14.2')).spawn(cmd);
|
|
153
|
+
}),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
expect(out).toBe('Client: Tag: v1.14.2');
|
|
157
|
+
expect(launched.length).toBe(2);
|
|
158
|
+
expect(launched[0]).toBe(join(realpathSync(real), 'talosctl'));
|
|
159
|
+
expect(launched[1]).toBe(launched[0]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('a refusal is a TalosBinaryRefused naming its source, never a TalosError', async () => {
|
|
163
|
+
const outcome = await Effect.runPromise(
|
|
164
|
+
Effect.flip(
|
|
165
|
+
Effect.provideService(
|
|
166
|
+
talosctl(['version'], { binary: 'relative/talosctl', talosconfigPath: 'x' }),
|
|
167
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
168
|
+
fakeSpawner(() => ({})),
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
expect(outcome).toBeInstanceOf(TalosBinaryRefused);
|
|
173
|
+
expect(outcome).not.toBeInstanceOf(TalosError);
|
|
174
|
+
expect(String(outcome)).toContain('the `binary` option');
|
|
175
|
+
expect(String(outcome)).not.toContain('HF_TALOSCTL');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('no process uid means ownership cannot be checked: refused (fails on 19502c6: passed)', async () => {
|
|
179
|
+
const bin = trusted('no-getuid');
|
|
180
|
+
const real = process.getuid;
|
|
181
|
+
Object.defineProperty(process, 'getuid', { configurable: true, value: undefined });
|
|
182
|
+
try {
|
|
183
|
+
expect(await run(bin, pinned('v1.14.2'))).toContain('no process uid');
|
|
184
|
+
} finally {
|
|
185
|
+
Object.defineProperty(process, 'getuid', { configurable: true, value: real });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('the version must match exactly: v1.14.20 and v1.13.8 fail, v1.14.2 passes', async () => {
|
|
190
|
+
const bin = trusted('versions');
|
|
191
|
+
expect(await run(bin, pinned('v1.14.20'))).toContain('v1.14.2');
|
|
192
|
+
expect(await run(bin, pinned('v1.13.8'))).toContain('v1.14.2');
|
|
193
|
+
expect(await run(bin, pinned('v1.14.2'))).toBe('Client: Tag: v1.14.2');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('the child gets a minimal env: BAO_TOKEN never reaches the binary, even the version probe', async () => {
|
|
197
|
+
const saved = process.env['BAO_TOKEN'];
|
|
198
|
+
process.env['BAO_TOKEN'] = 'fake-test-token';
|
|
199
|
+
const options: unknown[] = [];
|
|
200
|
+
try {
|
|
201
|
+
await run(trusted('env'), pinned('v1.14.2'), options);
|
|
202
|
+
} finally {
|
|
203
|
+
if (saved === undefined) delete process.env['BAO_TOKEN'];
|
|
204
|
+
else process.env['BAO_TOKEN'] = saved;
|
|
205
|
+
}
|
|
206
|
+
expect(options.length).toBe(2);
|
|
207
|
+
for (const option of options as { env?: Record<string, string>; extendEnv?: boolean }[]) {
|
|
208
|
+
expect(option.extendEnv).toBe(false);
|
|
209
|
+
expect(Object.keys(option.env ?? {})).not.toContain('BAO_TOKEN');
|
|
210
|
+
expect(Object.keys(option.env ?? {})).not.toContain('BAO_ADDR');
|
|
211
|
+
}
|
|
212
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* talosctl binary override. Fake spawner only — the named path is never executed (it only has to
|
|
3
|
+
* exist and be a trusted file; the fake answers `version --client` with the pinned version).
|
|
4
|
+
*/
|
|
5
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { afterAll, expect, test } from 'bun:test';
|
|
9
|
+
import * as Duration from 'effect/Duration';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
12
|
+
import { type FakeCall, fakeSpawner } from './fake-process.ts';
|
|
13
|
+
import { trustBoundaryForTests } from './trust-boundary.seam.ts';
|
|
14
|
+
import {
|
|
15
|
+
TALOSCTL_BINARY_ENV,
|
|
16
|
+
TalosBinaryRefused,
|
|
17
|
+
VERSION_PROBE_TIMEOUT,
|
|
18
|
+
talosctl,
|
|
19
|
+
} from './talosctl.ts';
|
|
20
|
+
|
|
21
|
+
// ⚠️ A real, vetted file in a private directory: the system `sh` or `bun` may be a symlink or live
|
|
22
|
+
// in a writable directory, which the vetting (correctly) refuses.
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), 'hf-talosctl-'));
|
|
24
|
+
chmodSync(dir, 0o755);
|
|
25
|
+
trustBoundaryForTests(dir);
|
|
26
|
+
const fixture = join(dir, 'talosctl');
|
|
27
|
+
writeFileSync(fixture, '#!/bin/sh\n', { mode: 0o755 });
|
|
28
|
+
chmodSync(fixture, 0o755);
|
|
29
|
+
afterAll(() => rmSync(dir, { force: true, recursive: true }));
|
|
30
|
+
|
|
31
|
+
const run = (binary: string | undefined, calls: FakeCall[]) =>
|
|
32
|
+
Effect.runPromise(
|
|
33
|
+
Effect.provideService(
|
|
34
|
+
talosctl(['version'], {
|
|
35
|
+
talosconfigPath: 'unused-talosconfig.yaml',
|
|
36
|
+
...(binary === undefined ? {} : { binary }),
|
|
37
|
+
}),
|
|
38
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
39
|
+
fakeSpawner(() => ({ stdout: 'Client: Tag: v1.14.2' }), calls),
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const withEnv = async (value: string | undefined, body: () => Promise<void>) => {
|
|
44
|
+
const saved = process.env[TALOSCTL_BINARY_ENV];
|
|
45
|
+
if (value === undefined) delete process.env[TALOSCTL_BINARY_ENV];
|
|
46
|
+
else process.env[TALOSCTL_BINARY_ENV] = value;
|
|
47
|
+
try {
|
|
48
|
+
await body();
|
|
49
|
+
} finally {
|
|
50
|
+
if (saved === undefined) delete process.env[TALOSCTL_BINARY_ENV];
|
|
51
|
+
else process.env[TALOSCTL_BINARY_ENV] = saved;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
test('the binary option wins over HF_TALOSCTL', async () => {
|
|
56
|
+
await withEnv('/definitely/not/here/talosctl', async () => {
|
|
57
|
+
const calls: FakeCall[] = [];
|
|
58
|
+
await run(fixture, calls);
|
|
59
|
+
expect(calls[0]?.command).toBe(fixture);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('HF_TALOSCTL selects the binary when the option is omitted', async () => {
|
|
64
|
+
await withEnv(fixture, async () => {
|
|
65
|
+
const calls: FakeCall[] = [];
|
|
66
|
+
await run(undefined, calls);
|
|
67
|
+
expect(calls[0]?.command).toBe(fixture);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const withPath = async (value: string, body: () => Promise<void>) => {
|
|
72
|
+
const saved = process.env['PATH'];
|
|
73
|
+
process.env['PATH'] = value;
|
|
74
|
+
try {
|
|
75
|
+
await body();
|
|
76
|
+
} finally {
|
|
77
|
+
if (saved === undefined) delete process.env['PATH'];
|
|
78
|
+
else process.env['PATH'] = saved;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
test('the default talosctl is resolved on PATH, vetted and version-checked by absolute path', async () => {
|
|
83
|
+
await withEnv(undefined, () =>
|
|
84
|
+
withPath(dir, async () => {
|
|
85
|
+
const calls: FakeCall[] = [];
|
|
86
|
+
await run(undefined, calls);
|
|
87
|
+
expect(calls.map((call) => call.command)).toEqual([fixture, fixture]);
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('the default talosctl on PATH is refused when it reports another version', async () => {
|
|
93
|
+
await withEnv(undefined, () =>
|
|
94
|
+
withPath(dir, async () => {
|
|
95
|
+
const outcome = await Effect.runPromise(
|
|
96
|
+
Effect.provideService(
|
|
97
|
+
talosctl(['version'], { talosconfigPath: 'unused.yaml' }),
|
|
98
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
99
|
+
fakeSpawner(() => ({ stdout: 'Client: Tag: v1.13.8' })),
|
|
100
|
+
),
|
|
101
|
+
).catch((e: unknown) => String(e));
|
|
102
|
+
expect(outcome).toContain('is not talosctl v1.14.2');
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a default talosctl that is not on PATH is refused, never run by bare name', async () => {
|
|
108
|
+
await withEnv(undefined, () =>
|
|
109
|
+
withPath(join(dir, 'empty'), async () => {
|
|
110
|
+
const calls: FakeCall[] = [];
|
|
111
|
+
const outcome = await run(undefined, calls).catch((e: unknown) => String(e));
|
|
112
|
+
expect(outcome).toContain('not found on PATH');
|
|
113
|
+
expect(calls).toEqual([]);
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a version probe that never answers is refused with a typed error, not awaited forever', async () => {
|
|
119
|
+
await withEnv(fixture, async () => {
|
|
120
|
+
const started = Date.now();
|
|
121
|
+
const outcome = await Effect.runPromise(
|
|
122
|
+
Effect.flip(
|
|
123
|
+
Effect.provideService(
|
|
124
|
+
// The default is 10 s (asserted below); a short override keeps the suite fast.
|
|
125
|
+
talosctl(['version'], {
|
|
126
|
+
talosconfigPath: 'unused.yaml',
|
|
127
|
+
versionProbeTimeout: '2 seconds',
|
|
128
|
+
}),
|
|
129
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
130
|
+
ChildProcessSpawner.make(() => Effect.never),
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
expect(outcome).toBeInstanceOf(TalosBinaryRefused);
|
|
135
|
+
expect(outcome.message).toContain('did not answer within');
|
|
136
|
+
expect(Date.now() - started).toBeLessThan(4000);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('the probe deadline is overridable per call and still fails closed', async () => {
|
|
141
|
+
await withEnv(fixture, async () => {
|
|
142
|
+
const started = Date.now();
|
|
143
|
+
const outcome = await Effect.runPromise(
|
|
144
|
+
Effect.flip(
|
|
145
|
+
Effect.provideService(
|
|
146
|
+
talosctl(['version'], {
|
|
147
|
+
talosconfigPath: 'unused.yaml',
|
|
148
|
+
versionProbeTimeout: '1 seconds',
|
|
149
|
+
}),
|
|
150
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
151
|
+
ChildProcessSpawner.make(() => Effect.never),
|
|
152
|
+
),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
expect(outcome).toBeInstanceOf(TalosBinaryRefused);
|
|
156
|
+
expect(outcome.message).toContain('did not answer within 1s');
|
|
157
|
+
expect(Date.now() - started).toBeLessThan(5000);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('the default probe deadline is 10 s', () => {
|
|
162
|
+
expect(Duration.toSeconds(VERSION_PROBE_TIMEOUT)).toBe(10);
|
|
163
|
+
});
|
package/src/talos/talosctl.ts
CHANGED
|
@@ -10,24 +10,65 @@
|
|
|
10
10
|
* ⛔ DO NOT LOG STDOUT ON FAILURE. `talosctl kubeconfig` and error paths have been observed in the
|
|
11
11
|
* wild to include credential material; stderr is the safe diagnostic channel.
|
|
12
12
|
*/
|
|
13
|
+
import * as Duration from 'effect/Duration';
|
|
13
14
|
import * as Effect from 'effect/Effect';
|
|
14
15
|
import * as Stream from 'effect/Stream';
|
|
15
16
|
import * as ChildProcess from 'effect/process/ChildProcess';
|
|
16
17
|
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_TALOSCTL_BINARY,
|
|
20
|
+
TALOSCTL_BINARY_ENV,
|
|
21
|
+
TalosBinaryRefused,
|
|
22
|
+
checkBinaryPath,
|
|
23
|
+
resolveDefault,
|
|
24
|
+
} from './talosctl-binary.ts';
|
|
25
|
+
|
|
26
|
+
export { DEFAULT_TALOSCTL_BINARY, TALOSCTL_BINARY_ENV, TalosBinaryRefused };
|
|
17
27
|
|
|
18
28
|
export class TalosError extends Error {
|
|
19
29
|
constructor(
|
|
20
30
|
readonly command: string,
|
|
21
31
|
readonly exitCode: number,
|
|
22
32
|
detail: string,
|
|
33
|
+
binary = DEFAULT_TALOSCTL_BINARY,
|
|
23
34
|
) {
|
|
24
|
-
super(
|
|
35
|
+
super(`${binary} ${command} -> ${String(exitCode)}: ${detail}`);
|
|
25
36
|
this.name = 'TalosError';
|
|
26
37
|
}
|
|
27
38
|
}
|
|
28
39
|
|
|
40
|
+
/** The Talos client version this family is written against (`--client` of the pinned binary). */
|
|
41
|
+
export const TALOSCTL_PINNED_VERSION = 'v1.14.2';
|
|
42
|
+
|
|
43
|
+
const talosctlBinary = (override: string | undefined): string => {
|
|
44
|
+
const fromOption = override?.trim();
|
|
45
|
+
if (fromOption !== undefined && fromOption !== '') return fromOption;
|
|
46
|
+
const fromEnv = process.env[TALOSCTL_BINARY_ENV]?.trim();
|
|
47
|
+
if (fromEnv !== undefined && fromEnv !== '') return fromEnv;
|
|
48
|
+
return DEFAULT_TALOSCTL_BINARY;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ⛔ THE CHILD GETS A MINIMAL ENV, NOT `process.env`: BAO_TOKEN and every other credential in the
|
|
53
|
+
* caller's environment stay out of an override that is only vetted by path and version. The
|
|
54
|
+
* talosconfig travels as a file argument, so the binary needs nothing else but `PATH`/`HOME`.
|
|
55
|
+
*/
|
|
56
|
+
const talosctlEnv = (): Record<string, string> => {
|
|
57
|
+
const env: Record<string, string> = {};
|
|
58
|
+
for (const name of ['PATH', 'HOME', 'TMPDIR']) {
|
|
59
|
+
const value = process.env[name];
|
|
60
|
+
if (value !== undefined && value !== '') env[name] = value;
|
|
61
|
+
}
|
|
62
|
+
return env;
|
|
63
|
+
};
|
|
64
|
+
|
|
29
65
|
export type TalosRunOptions = {
|
|
30
66
|
readonly talosconfigPath: string;
|
|
67
|
+
/**
|
|
68
|
+
* `talosctl` executable. Wins over {@link TALOSCTL_BINARY_ENV}.
|
|
69
|
+
* @default `talosctl` on `PATH`, or `HF_TALOSCTL` when that variable is set.
|
|
70
|
+
*/
|
|
71
|
+
readonly binary?: string;
|
|
31
72
|
readonly nodes?: readonly string[];
|
|
32
73
|
readonly endpoints?: readonly string[];
|
|
33
74
|
/**
|
|
@@ -37,23 +78,25 @@ export type TalosRunOptions = {
|
|
|
37
78
|
* since insecure mode ignores the file's certs rather than requiring their absence.
|
|
38
79
|
*/
|
|
39
80
|
readonly insecure?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Deadline for the `version --client` probe of the binary; past it the binary is refused (fails
|
|
83
|
+
* closed, never skipped). A cold first run of the Go binary under CI load can exceed a tight
|
|
84
|
+
* limit, so a caller may raise it.
|
|
85
|
+
* @default {@link VERSION_PROBE_TIMEOUT} (10 s)
|
|
86
|
+
*/
|
|
87
|
+
readonly versionProbeTimeout?: Duration.Input;
|
|
40
88
|
};
|
|
41
89
|
|
|
42
|
-
|
|
43
|
-
export const talosctl = (args: readonly string[], options: TalosRunOptions) =>
|
|
90
|
+
const capture = (binary: string, argv: readonly string[]) =>
|
|
44
91
|
Effect.gen(function* () {
|
|
45
92
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
];
|
|
54
|
-
const result = yield* ChildProcess.make('talosctl', argv, {
|
|
55
|
-
detached: false,
|
|
56
|
-
extendEnv: true,
|
|
93
|
+
return yield* ChildProcess.make(binary, argv, {
|
|
94
|
+
// ⛔ No `detached: false`, plus `forceKillAfter`, as credentials.ts's bao spawn: the child
|
|
95
|
+
// leads its own process group (killed whole on interrupt) and a SIGTERM-ignoring talosctl
|
|
96
|
+
// is SIGKILLed 1 s later instead of blocking interruption.
|
|
97
|
+
env: talosctlEnv(),
|
|
98
|
+
extendEnv: false,
|
|
99
|
+
forceKillAfter: Duration.seconds(1),
|
|
57
100
|
stderr: 'pipe',
|
|
58
101
|
stdin: 'ignore',
|
|
59
102
|
stdout: 'pipe',
|
|
@@ -79,11 +122,86 @@ export const talosctl = (args: readonly string[], options: TalosRunOptions) =>
|
|
|
79
122
|
),
|
|
80
123
|
Effect.scoped,
|
|
81
124
|
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* ⛔ THE VERSION PROBE IS BOUNDED. `talosctl version --client` talks to no cluster and answers in
|
|
129
|
+
* milliseconds, so a probe silent for this long is a hung or hostile binary: it is refused
|
|
130
|
+
* (failed closed, typed) instead of parking the caller forever. Interrupting the scope kills the
|
|
131
|
+
* child with its process group (proved against a real hanging child by talosctl-probe-kill.test.ts).
|
|
132
|
+
* ⚠️ 10 s, not 3 s: a cold first run of the Go binary under CI load can take seconds, and a
|
|
133
|
+
* refusal there is a false alarm. Override per call with `TalosRunOptions.versionProbeTimeout`.
|
|
134
|
+
*/
|
|
135
|
+
export const VERSION_PROBE_TIMEOUT = Duration.seconds(10);
|
|
136
|
+
|
|
137
|
+
/** ⚠️ EXACT TOKEN MATCH: `includes` would accept v1.14.20 for v1.14.2. */
|
|
138
|
+
const reportsPinnedVersion = (stdout: string) =>
|
|
139
|
+
stdout.split(/[^\w.+-]+/).includes(TALOSCTL_PINNED_VERSION);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* ⛔ AN OVERRIDE MUST REPORT THE PINNED CLIENT VERSION (`version --client`, no talosconfig, so
|
|
143
|
+
* nothing secret reaches an untrusted binary before it is vetted).
|
|
144
|
+
*/
|
|
145
|
+
const checkOverrideVersion = (
|
|
146
|
+
binary: string,
|
|
147
|
+
source: string,
|
|
148
|
+
timeout: Duration.Input = VERSION_PROBE_TIMEOUT,
|
|
149
|
+
) =>
|
|
150
|
+
capture(binary, ['version', '--client']).pipe(
|
|
151
|
+
Effect.timeoutOrElse({
|
|
152
|
+
duration: timeout,
|
|
153
|
+
orElse: () =>
|
|
154
|
+
Effect.fail(
|
|
155
|
+
new TalosBinaryRefused(
|
|
156
|
+
'version --client',
|
|
157
|
+
`${source} did not answer within ${String(Duration.toSeconds(timeout))}s`,
|
|
158
|
+
binary,
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
}),
|
|
162
|
+
Effect.flatMap((out) =>
|
|
163
|
+
out.exitCode === 0 && reportsPinnedVersion(out.stdout)
|
|
164
|
+
? Effect.void
|
|
165
|
+
: Effect.fail(
|
|
166
|
+
new TalosBinaryRefused(
|
|
167
|
+
'version --client',
|
|
168
|
+
`${source} is not talosctl ${TALOSCTL_PINNED_VERSION} (exit ${String(out.exitCode)})`,
|
|
169
|
+
binary,
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
/** Run `talosctl <args…>`. Returns trimmed stdout, or '' when empty. */
|
|
176
|
+
export const talosctl = (args: readonly string[], options: TalosRunOptions) =>
|
|
177
|
+
Effect.gen(function* () {
|
|
178
|
+
const requested = talosctlBinary(options.binary);
|
|
179
|
+
const resolved =
|
|
180
|
+
requested === DEFAULT_TALOSCTL_BINARY ? yield* resolveDefault(requested) : requested;
|
|
181
|
+
const fromOption = options.binary?.trim();
|
|
182
|
+
const source =
|
|
183
|
+
requested === DEFAULT_TALOSCTL_BINARY
|
|
184
|
+
? 'talosctl on PATH'
|
|
185
|
+
: fromOption !== undefined && fromOption !== ''
|
|
186
|
+
? 'the `binary` option'
|
|
187
|
+
: TALOSCTL_BINARY_ENV;
|
|
188
|
+
// ⛔ Launch exactly the vetted canonical path, for the probe and the credentialed call alike.
|
|
189
|
+
const binary = yield* checkBinaryPath(resolved, source);
|
|
190
|
+
yield* checkOverrideVersion(binary, source, options.versionProbeTimeout);
|
|
191
|
+
const argv = [
|
|
192
|
+
...args,
|
|
193
|
+
'--talosconfig',
|
|
194
|
+
options.talosconfigPath,
|
|
195
|
+
...(options.nodes === undefined ? [] : ['--nodes', options.nodes.join(',')]),
|
|
196
|
+
...(options.endpoints === undefined ? [] : ['--endpoints', options.endpoints.join(',')]),
|
|
197
|
+
...(options.insecure === true ? ['--insecure'] : []),
|
|
198
|
+
];
|
|
199
|
+
const result = yield* capture(binary, argv);
|
|
82
200
|
|
|
83
201
|
const command = args.join(' ');
|
|
84
202
|
if (result.exitCode !== 0) {
|
|
85
203
|
return yield* Effect.fail(
|
|
86
|
-
new TalosError(command, result.exitCode, result.stderr.slice(0, 300)),
|
|
204
|
+
new TalosError(command, result.exitCode, result.stderr.slice(0, 300), binary),
|
|
87
205
|
);
|
|
88
206
|
}
|
|
89
207
|
return result.stdout;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shipped trust-boundary surface. ⛔ The talosctl ancestor walk stops early above a
|
|
3
|
+
* registered directory, so anything that can register a directory can switch that vetting off —
|
|
4
|
+
* the module a consumer imports must therefore offer no way to register one. The register
|
|
5
|
+
* function lives in trust-boundary.seam.ts, which the published tarball excludes (package.json
|
|
6
|
+
* `files`, asserted by scripts/smoke.ts), so a non-test import of this module cannot reach it.
|
|
7
|
+
*/
|
|
8
|
+
import { expect, test } from 'bun:test';
|
|
9
|
+
import * as shipped from './trust-boundary.ts';
|
|
10
|
+
|
|
11
|
+
test('the shipped module exposes only the read-only boundary query', () => {
|
|
12
|
+
expect(Object.keys(shipped).sort()).toEqual(['isTrustedBoundary']);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('isTrustedBoundary answers false for an unregistered directory', () => {
|
|
16
|
+
expect(shipped.isTrustedBoundary('/definitely/not/registered')).toBe(false);
|
|
17
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which directories the talosctl ancestor walk (talosctl-binary.ts) may stop above.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ READ-ONLY SHIPPED SURFACE, TEST-ONLY BY CONSTRUCTION. This is the only module the walk
|
|
5
|
+
* consults, and its only export is the query below — the register function lives in
|
|
6
|
+
* `trust-boundary.seam.ts`, which the published tarball does not contain (package.json `files`
|
|
7
|
+
* excludes it; scripts/smoke.ts asserts that). A non-test import of this module therefore
|
|
8
|
+
* cannot stop the walk early: `trustBoundaryForTests` is unreachable, the set it feeds is not
|
|
9
|
+
* exported, and in a consumer install the dynamic pull below finds no seam, keeps the empty
|
|
10
|
+
* set and every ancestor of a binary is vetted (fail closed). The seam is pulled with a
|
|
11
|
+
* COMPUTED specifier so no bundler inlines it into the shipped module.
|
|
12
|
+
*
|
|
13
|
+
* Plain syntax on purpose: fake-process.ts's graph is loaded under Node's strip-only
|
|
14
|
+
* TypeScript (node-connect.harness.ts), which cannot load talosctl-binary.ts (parameter
|
|
15
|
+
* properties). Not exported from the subpath index.
|
|
16
|
+
*/
|
|
17
|
+
import { realpathSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
let boundaries: ReadonlySet<string> = new Set<string>();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Canonical compare: the walk hands over a real path, but the boundary is a security decision —
|
|
23
|
+
* resolving again makes it independent of what a caller (now or future) cached. ⛔ A directory
|
|
24
|
+
* that cannot be resolved is answered FALSE, never thrown: it is certainly not a registered
|
|
25
|
+
* boundary, and the walk must treat "no boundary" as "vet everything" (fail closed).
|
|
26
|
+
*/
|
|
27
|
+
export const isTrustedBoundary = (dir: string): boolean => {
|
|
28
|
+
try {
|
|
29
|
+
return boundaries.has(realpathSync(dir));
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const seamSpecifier = './trust-boundary' + '.seam.ts';
|
|
36
|
+
void import(seamSpecifier).then(
|
|
37
|
+
(seam: { boundaries: ReadonlySet<string> }) => {
|
|
38
|
+
boundaries = seam.boundaries;
|
|
39
|
+
},
|
|
40
|
+
() => {
|
|
41
|
+
// Published package: no seam in the tarball — the set stays empty and the walk is never
|
|
42
|
+
// shortened. In-repo this only fires if the seam file was deleted; boundary-dependent tests
|
|
43
|
+
// then fail loudly instead of passing short-handed.
|
|
44
|
+
},
|
|
45
|
+
);
|
package/src/talos/values.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* reconcile time; tests use fixtures only.
|
|
7
7
|
*/
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
|
+
import { parseYaml } from './yaml-parse.ts';
|
|
9
10
|
|
|
10
11
|
/** Trim trailing whitespace only — leading space in YAML is meaningful. */
|
|
11
12
|
export const canonicalText = (text: string) => text.trimEnd();
|
|
@@ -39,12 +40,7 @@ export const configDigest = (text: string) => sha256(text);
|
|
|
39
40
|
* silently picking one would repeat the exact shipped bug.
|
|
40
41
|
*/
|
|
41
42
|
export const extractMachineConfigSpec = (wrapperYaml: string): string | undefined => {
|
|
42
|
-
let doc
|
|
43
|
-
try {
|
|
44
|
-
doc = Bun.YAML.parse(wrapperYaml);
|
|
45
|
-
} catch {
|
|
46
|
-
return undefined;
|
|
47
|
-
}
|
|
43
|
+
let doc = parseYaml(wrapperYaml);
|
|
48
44
|
if (Array.isArray(doc)) {
|
|
49
45
|
if (doc.length !== 1) return undefined;
|
|
50
46
|
doc = doc[0];
|
|
@@ -77,12 +73,9 @@ export const kubeconfigMetadata = (
|
|
|
77
73
|
yamlText: string,
|
|
78
74
|
contextName: string,
|
|
79
75
|
): { endpoint: string; caFingerprint: string; clientFingerprint: string } | undefined => {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
} catch {
|
|
84
|
-
return undefined;
|
|
85
|
-
}
|
|
76
|
+
const parsed = parseYaml(yamlText);
|
|
77
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
|
|
78
|
+
const doc = parsed as KubeconfigDoc;
|
|
86
79
|
|
|
87
80
|
const ctx = doc.contexts?.find((row) => row.name === contextName)?.context;
|
|
88
81
|
const clusterName = ctx?.cluster;
|