@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
|
@@ -22,13 +22,19 @@
|
|
|
22
22
|
* rollout would never learn the real problem was vault. Both `read` and `reconcile` now only
|
|
23
23
|
* catch `TalosError` (`talosctl health` itself ran and exited non-zero — the one case this
|
|
24
24
|
* family may honestly call "not healthy"); anything else propagates untouched.
|
|
25
|
+
* ★ `connection` IS A PASS-THROUGH so this resource is `ClusterLike`. Later rows take
|
|
26
|
+
* `cluster: health`. The health check itself still uses `target` and `talosctl`; the field is
|
|
27
|
+
* copied onto attributes and is not a credential. It is required: an optional field would not
|
|
28
|
+
* satisfy `{ connection: Connection }`.
|
|
25
29
|
*/
|
|
26
30
|
import { Resource } from 'alchemy';
|
|
27
31
|
import { isResolved } from 'alchemy/Diff';
|
|
28
32
|
import type { Input } from 'alchemy/Input';
|
|
29
33
|
import * as Provider from 'alchemy/Provider';
|
|
30
34
|
import * as Effect from 'effect/Effect';
|
|
35
|
+
import type { TalosOpenBaoConnection } from './cluster-adapter.ts';
|
|
31
36
|
import { mintTalosconfig } from './credentials.ts';
|
|
37
|
+
import { literalConnectionRefusal, withLiteralConnection } from './literal-connection.ts';
|
|
32
38
|
import type { TalosRequirements, WithTarget } from './resource.ts';
|
|
33
39
|
import { TalosError, talosctl } from './talosctl.ts';
|
|
34
40
|
|
|
@@ -49,12 +55,19 @@ export interface ClusterHealthProps extends WithTarget {
|
|
|
49
55
|
* on kube-proxy and CoreDNS, both of which wait on a CNI that does not exist yet at bootstrap.
|
|
50
56
|
*/
|
|
51
57
|
after?: readonly unknown[];
|
|
58
|
+
/**
|
|
59
|
+
* Kubernetes connection copied onto attributes so a later row can take `cluster: this`.
|
|
60
|
+
* Not read by the health check. Persist only a secret-free connection (`talos-openbao`).
|
|
61
|
+
*/
|
|
62
|
+
connection: TalosOpenBaoConnection;
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
export interface ClusterHealthAttributes {
|
|
55
66
|
healthy: boolean;
|
|
56
67
|
controlPlaneNodes: string;
|
|
57
68
|
workerNodes: string;
|
|
69
|
+
/** Same object as the prop. Makes these attributes `ClusterLike`. */
|
|
70
|
+
connection: TalosOpenBaoConnection;
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
export interface TalosClusterHealth extends Resource<
|
|
@@ -65,11 +78,34 @@ export interface TalosClusterHealth extends Resource<
|
|
|
65
78
|
TalosRequirements
|
|
66
79
|
> {}
|
|
67
80
|
|
|
68
|
-
|
|
81
|
+
// ⛔ Declaration-time literal guard: a fresh resource skips `diff` (see `withLiteralConnection`).
|
|
82
|
+
export const TalosClusterHealth = withLiteralConnection(
|
|
83
|
+
Resource<TalosClusterHealth>('Talos.ClusterHealth'),
|
|
84
|
+
);
|
|
69
85
|
|
|
70
86
|
const nodeCsv = (nodes: readonly string[] | undefined) =>
|
|
71
87
|
nodes === undefined || nodes.length === 0 ? '' : nodes.join(',');
|
|
72
88
|
|
|
89
|
+
const attributes = (props: ClusterHealthProps, healthy: boolean): ClusterHealthAttributes => ({
|
|
90
|
+
connection: props.connection,
|
|
91
|
+
controlPlaneNodes: nodeCsv(props.controlPlaneNodes),
|
|
92
|
+
healthy,
|
|
93
|
+
workerNodes: nodeCsv(props.workerNodes),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* ⚠️ A ROW SAVED BEFORE `connection` EXISTED HAS NONE — `right` is `undefined` at runtime despite
|
|
98
|
+
* the type, and reading `right.auth` threw on a healthy cluster. Missing means "update".
|
|
99
|
+
*/
|
|
100
|
+
const sameConnection = (
|
|
101
|
+
left: TalosOpenBaoConnection,
|
|
102
|
+
right: TalosOpenBaoConnection | undefined,
|
|
103
|
+
): boolean =>
|
|
104
|
+
right?.auth?.kind === left.auth.kind &&
|
|
105
|
+
right.auth.uid === left.auth.uid &&
|
|
106
|
+
// ⚠️ A row that still carries the legacy `auth.cluster` alias must update, so it leaves state.
|
|
107
|
+
!Object.hasOwn(right.auth, 'cluster');
|
|
108
|
+
|
|
73
109
|
const healthArgs = (props: ClusterHealthProps, waitTimeout: string) => {
|
|
74
110
|
const args = ['health', '--wait-timeout', waitTimeout];
|
|
75
111
|
const cp = nodeCsv(props.controlPlaneNodes);
|
|
@@ -113,11 +149,7 @@ const check = (props: ClusterHealthProps, waitTimeout: string) =>
|
|
|
113
149
|
nodes: [contact],
|
|
114
150
|
talosconfigPath: credential.talosconfigPath,
|
|
115
151
|
});
|
|
116
|
-
return
|
|
117
|
-
controlPlaneNodes: nodeCsv(props.controlPlaneNodes),
|
|
118
|
-
healthy: true,
|
|
119
|
-
workerNodes: nodeCsv(props.workerNodes),
|
|
120
|
-
};
|
|
152
|
+
return attributes(props, true);
|
|
121
153
|
}),
|
|
122
154
|
);
|
|
123
155
|
|
|
@@ -132,13 +164,7 @@ const isTalosError = (cause: unknown): cause is TalosError => cause instanceof T
|
|
|
132
164
|
/** ★ EXPORTED for talos-cluster-health.test.ts — see talos-bootstrap.ts's own note on the pattern. */
|
|
133
165
|
export const readClusterHealth = (props: ClusterHealthProps) =>
|
|
134
166
|
check(props, '5s').pipe(
|
|
135
|
-
Effect.catchIf(isTalosError, () =>
|
|
136
|
-
Effect.succeed({
|
|
137
|
-
controlPlaneNodes: nodeCsv(props.controlPlaneNodes),
|
|
138
|
-
healthy: false,
|
|
139
|
-
workerNodes: nodeCsv(props.workerNodes),
|
|
140
|
-
}),
|
|
141
|
-
),
|
|
167
|
+
Effect.catchIf(isTalosError, () => Effect.succeed(attributes(props, false))),
|
|
142
168
|
);
|
|
143
169
|
|
|
144
170
|
export const diffClusterHealth = (
|
|
@@ -146,9 +172,15 @@ export const diffClusterHealth = (
|
|
|
146
172
|
output: ClusterHealthAttributes | undefined,
|
|
147
173
|
) =>
|
|
148
174
|
Effect.gen(function* () {
|
|
175
|
+
// ⛔ `cluster: health` hands this connection to workloads: refuse an Output here, while it is
|
|
176
|
+
// still an Input (`literal-connection.ts`).
|
|
177
|
+
const refused = literalConnectionRefusal((news as { connection?: unknown }).connection);
|
|
178
|
+
if (refused !== undefined) return yield* Effect.fail(refused);
|
|
149
179
|
if (output === undefined || !isResolved(news)) return undefined;
|
|
150
180
|
const live = yield* readClusterHealth(news);
|
|
151
|
-
if (live.healthy
|
|
181
|
+
if (live.healthy && sameConnection(live.connection, output.connection)) {
|
|
182
|
+
return { action: 'noop' } as const;
|
|
183
|
+
}
|
|
152
184
|
return { action: 'update' } as const;
|
|
153
185
|
});
|
|
154
186
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Talos.ClusterIdentity` — publishes the physical cluster's uid for the operator to pin. Fake `bao` and fake apiserver only; nothing is written.
|
|
3
|
+
*/
|
|
4
|
+
import { expect, test } from 'bun:test';
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
7
|
+
import { fakeApiServer } from './fake-apiserver.ts';
|
|
8
|
+
import { fakeSpawner } from './fake-process.ts';
|
|
9
|
+
import {
|
|
10
|
+
diffClusterIdentity,
|
|
11
|
+
readClusterIdentity,
|
|
12
|
+
reconcileClusterIdentity,
|
|
13
|
+
} from './talos-cluster-identity.ts';
|
|
14
|
+
|
|
15
|
+
const b64 = (text: string) => Buffer.from(text).toString('base64');
|
|
16
|
+
const pem = (label: string) => `-----${'BEGIN'} ${label}-----\n${b64(label)}\n`;
|
|
17
|
+
const kubeconfig = `apiVersion: v1
|
|
18
|
+
kind: Config
|
|
19
|
+
clusters:
|
|
20
|
+
- name: c1
|
|
21
|
+
cluster: { server: 'https://c1.cluster.invalid:6443', certificate-authority-data: ${b64(pem('CERTIFICATE'))} }
|
|
22
|
+
contexts:
|
|
23
|
+
- name: admin@c1
|
|
24
|
+
context: { cluster: c1, user: admin@c1 }
|
|
25
|
+
users:
|
|
26
|
+
- name: admin@c1
|
|
27
|
+
user: { client-certificate-data: ${b64(pem('CERTIFICATE'))}, client-key-data: ${b64(pem('PRIVATE KEY'))} }
|
|
28
|
+
`;
|
|
29
|
+
const props = { context: 'admin@c1', target: { cluster: 'c1', mount: 'talos-c1' } };
|
|
30
|
+
const run = <A, E>(effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>) =>
|
|
31
|
+
Effect.runPromise(
|
|
32
|
+
Effect.provideService(
|
|
33
|
+
effect,
|
|
34
|
+
ChildProcessSpawner.ChildProcessSpawner,
|
|
35
|
+
fakeSpawner(() => ({ stdout: JSON.stringify({ data: { data: { kubeconfig } } }) })),
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
test('reads the kube-system uid and publishes { uid }', async () => {
|
|
40
|
+
const api = fakeApiServer({ 'c1.cluster.invalid': 'uid-c1' });
|
|
41
|
+
try {
|
|
42
|
+
const out = await run(readClusterIdentity(props));
|
|
43
|
+
expect(out).toEqual({ uid: 'uid-c1' });
|
|
44
|
+
expect(api.seen).toEqual(['GET c1.cluster.invalid/api/v1/namespaces/kube-system']);
|
|
45
|
+
} finally {
|
|
46
|
+
api.restore();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const prior = { uid: 'uid-c1' } as const;
|
|
51
|
+
|
|
52
|
+
test('an unchanged uid is a noop', async () => {
|
|
53
|
+
const api = fakeApiServer({ 'c1.cluster.invalid': 'uid-c1' });
|
|
54
|
+
try {
|
|
55
|
+
expect((await run(diffClusterIdentity(props, prior)))?.action).toBe('noop');
|
|
56
|
+
} finally {
|
|
57
|
+
api.restore();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ⛔ A changed uid must never be an `update`: downstream workloads would be force-applied onto the
|
|
62
|
+
// new cluster (see the header of talos-cluster-identity.ts).
|
|
63
|
+
test('a changed uid is refused in diff AND reconcile, never an update', async () => {
|
|
64
|
+
const moved = fakeApiServer({ 'c1.cluster.invalid': 'uid-c2' });
|
|
65
|
+
try {
|
|
66
|
+
const inDiff = await run(diffClusterIdentity(props, prior)).catch((e: unknown) => String(e));
|
|
67
|
+
expect(inDiff).toContain('TalosClusterMoved');
|
|
68
|
+
expect(inDiff).toContain('uid-c2');
|
|
69
|
+
const inReconcile = await run(reconcileClusterIdentity(props, prior)).catch((e: unknown) =>
|
|
70
|
+
String(e),
|
|
71
|
+
);
|
|
72
|
+
expect(inReconcile).toContain('TalosClusterMoved');
|
|
73
|
+
} finally {
|
|
74
|
+
moved.restore();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('reconcile with no saved output publishes the live uid', async () => {
|
|
79
|
+
const api = fakeApiServer({ 'c1.cluster.invalid': 'uid-c2' });
|
|
80
|
+
try {
|
|
81
|
+
const out = await run(reconcileClusterIdentity(props, undefined));
|
|
82
|
+
expect(out.uid).toBe('uid-c2');
|
|
83
|
+
} finally {
|
|
84
|
+
api.restore();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('an apiserver answering without a uid fails closed', async () => {
|
|
89
|
+
const api = fakeApiServer({});
|
|
90
|
+
try {
|
|
91
|
+
const failure = await run(readClusterIdentity(props)).catch((e: unknown) => String(e));
|
|
92
|
+
expect(failure).toContain('TalosClusterIdentityUnreadable');
|
|
93
|
+
} finally {
|
|
94
|
+
api.restore();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Talos.ClusterIdentity` — a read-only resource that reports WHICH physical cluster a name means.
|
|
3
|
+
*
|
|
4
|
+
* ★ It reads the vault kubeconfig and GETs the kube-system Namespace once (`cluster-identity.ts`),
|
|
5
|
+
* and publishes `{ uid }`. That is the BOOTSTRAP-THEN-PIN step (cluster-adapter.ts header): the
|
|
6
|
+
* first deploy creates the cluster and this row with no workloads, the operator copies the
|
|
7
|
+
* printed uid into the adapter config, and workloads take `talosOpenBaoConnection(<literal>)`.
|
|
8
|
+
* ⛔ It publishes NO `connection`: an Output uid in a workload's auth is unresolved at plan time
|
|
9
|
+
* and hides a cluster change from the plan (`talosOpenBaoConnection` explains the DELETE on B).
|
|
10
|
+
* ⛔ DRIFT REPAIR CANNOT LAUNDER A MOVE: `read` receives the persisted output and compares it, so
|
|
11
|
+
* `Drift.repair` fails with `TalosClusterMoved` instead of reconciling B against B.
|
|
12
|
+
* ⛔ IT MUTATES NOTHING: no vault write, no cluster write, `delete` is a no-op. Its only effect is
|
|
13
|
+
* the value it reports. `after` must reach past Cilium/CNI only if the apiserver needs it; the
|
|
14
|
+
* kube-system namespace exists as soon as the apiserver answers.
|
|
15
|
+
* ⛔ A CHANGED UID IS REFUSED, NEVER AN UPDATE (`TalosClusterMoved`, in diff AND reconcile). This
|
|
16
|
+
* resource declares no `stables`, so downstream the engine sees an unresolved Output, every
|
|
17
|
+
* workload `diff` returns undefined and the engine plans an UPDATE, not a replace (Plan.ts
|
|
18
|
+
* resourceExpr, `isResolved(news)` in Manifest.ts and HelmChart.ts). Reconcile would then
|
|
19
|
+
* connect with the NEW uid and force-apply onto the new cluster while the old cluster's objects
|
|
20
|
+
* are orphaned (an earlier comment here claimed a replace and a refusal at cleanup: it cannot
|
|
21
|
+
* happen). A real move is a NEW identity resource per physical cluster.
|
|
22
|
+
*/
|
|
23
|
+
import { Resource } from 'alchemy';
|
|
24
|
+
import { isResolved } from 'alchemy/Diff';
|
|
25
|
+
import type { Input } from 'alchemy/Input';
|
|
26
|
+
import * as Provider from 'alchemy/Provider';
|
|
27
|
+
import * as Effect from 'effect/Effect';
|
|
28
|
+
import { TalosClusterMoved } from './cluster-adapter-errors.ts';
|
|
29
|
+
import { readClusterUid } from './cluster-identity.ts';
|
|
30
|
+
import { openTransport, talosVaultLocation, withConnectDeadline } from './cluster-transport.ts';
|
|
31
|
+
import type { KubeconfigProps } from './kubeconfig.ts';
|
|
32
|
+
import type { TalosRequirements } from './resource.ts';
|
|
33
|
+
|
|
34
|
+
type KubeconfigSource = Pick<KubeconfigProps, 'context' | 'kubeconfigKey' | 'target'>;
|
|
35
|
+
|
|
36
|
+
export interface ClusterIdentityProps extends KubeconfigSource {
|
|
37
|
+
/** Ordering edge: after `Talos.Kubeconfig` (the vault key must exist) and the apiserver is up. */
|
|
38
|
+
after?: readonly unknown[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ClusterIdentityAttributes {
|
|
42
|
+
/**
|
|
43
|
+
* The cluster's kube-system `metadata.uid`. Public. ⛔ NOT A `connection`: these attributes are
|
|
44
|
+
* deliberately not `ClusterLike`. Pin this value as a literal in `TalosOpenBaoAdapter` and build
|
|
45
|
+
* workloads' connection from that literal (bootstrap-then-pin, cluster-adapter.ts header).
|
|
46
|
+
*/
|
|
47
|
+
uid: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TalosClusterIdentity extends Resource<
|
|
51
|
+
'Talos.ClusterIdentity',
|
|
52
|
+
ClusterIdentityProps,
|
|
53
|
+
ClusterIdentityAttributes,
|
|
54
|
+
never,
|
|
55
|
+
TalosRequirements
|
|
56
|
+
> {}
|
|
57
|
+
|
|
58
|
+
export const TalosClusterIdentity = Resource<TalosClusterIdentity>('Talos.ClusterIdentity');
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* ★ EXPORTED for talos-cluster-identity.test.ts — see talos-bootstrap.ts's note on the pattern.
|
|
62
|
+
* ⛔ A PERSISTED OUTPUT IS COMPARED HERE TOO, not only in reconcile: `Drift.repair` hands `read`
|
|
63
|
+
* the persisted attributes, then reconciles `news: old.props, output: <what read returned>`.
|
|
64
|
+
* A read that ignored the output returned the NEW uid, so the reconcile guard compared B with B
|
|
65
|
+
* and saved it. Failing in read stops that path with `TalosClusterMoved`.
|
|
66
|
+
*/
|
|
67
|
+
export const readClusterIdentity = (
|
|
68
|
+
props: ClusterIdentityProps,
|
|
69
|
+
output?: ClusterIdentityAttributes,
|
|
70
|
+
) =>
|
|
71
|
+
Effect.gen(function* () {
|
|
72
|
+
const cluster = props.target.cluster;
|
|
73
|
+
// ⛔ Same single deadline as connect: a hung `bao` must not hang the plan.
|
|
74
|
+
const uid = yield* withConnectDeadline(
|
|
75
|
+
cluster,
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const transport = yield* openTransport(talosVaultLocation(props));
|
|
78
|
+
return yield* readClusterUid(cluster, transport);
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
if (output !== undefined && uid !== output.uid) {
|
|
82
|
+
return yield* Effect.fail(new TalosClusterMoved({ cluster, live: uid, saved: output.uid }));
|
|
83
|
+
}
|
|
84
|
+
return { uid };
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/** ⛔ Fails closed when a saved uid exists and the live one differs. See the header. */
|
|
88
|
+
export const reconcileClusterIdentity = (
|
|
89
|
+
props: ClusterIdentityProps,
|
|
90
|
+
output: ClusterIdentityAttributes | undefined,
|
|
91
|
+
) => readClusterIdentity(props, output);
|
|
92
|
+
|
|
93
|
+
export const diffClusterIdentity = (
|
|
94
|
+
news: Input<ClusterIdentityProps>,
|
|
95
|
+
output: ClusterIdentityAttributes | undefined,
|
|
96
|
+
) =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
99
|
+
yield* reconcileClusterIdentity(news, output);
|
|
100
|
+
return { action: 'noop' } as const;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const handlers = {
|
|
104
|
+
delete: () => Effect.void,
|
|
105
|
+
diff: ({
|
|
106
|
+
news,
|
|
107
|
+
output,
|
|
108
|
+
}: {
|
|
109
|
+
news: Input<ClusterIdentityProps>;
|
|
110
|
+
output: ClusterIdentityAttributes | undefined;
|
|
111
|
+
}) => diffClusterIdentity(news, output),
|
|
112
|
+
list: () => Effect.succeed([]),
|
|
113
|
+
read: ({
|
|
114
|
+
olds,
|
|
115
|
+
output,
|
|
116
|
+
}: {
|
|
117
|
+
olds: ClusterIdentityProps;
|
|
118
|
+
output: ClusterIdentityAttributes | undefined;
|
|
119
|
+
}) => readClusterIdentity(olds, output),
|
|
120
|
+
reconcile: ({
|
|
121
|
+
news,
|
|
122
|
+
output,
|
|
123
|
+
}: {
|
|
124
|
+
news: ClusterIdentityProps;
|
|
125
|
+
output: ClusterIdentityAttributes | undefined;
|
|
126
|
+
}) => reconcileClusterIdentity(news, output),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const TalosClusterIdentityProvider = () =>
|
|
130
|
+
Provider.effect(TalosClusterIdentity, Effect.succeed(TalosClusterIdentity.Provider.of(handlers)));
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which `talosctl` runs, and whether that file may be trusted with the vault-minted talosconfig.
|
|
3
|
+
* Extracted from talosctl.ts (250-line cap).
|
|
4
|
+
*
|
|
5
|
+
* ⛔ A VETTING REFUSAL IS NOT A TalosError. `TalosError` means a `talosctl` run completed and
|
|
6
|
+
* exited non-zero — the one outcome `Talos.ClusterHealth` may call "not healthy", and the one
|
|
7
|
+
* `talosctlOrAlready` pattern-matches. A refused binary never ran, so it is its own class and
|
|
8
|
+
* propagates through both untouched (hunt round 6: it read as `healthy: false`).
|
|
9
|
+
*/
|
|
10
|
+
import { accessSync, constants, lstatSync, realpathSync } from 'node:fs';
|
|
11
|
+
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import { isTrustedBoundary } from './trust-boundary.ts';
|
|
14
|
+
|
|
15
|
+
/** `talosctl` on `PATH`. A lane pins another build with {@link TALOSCTL_BINARY_ENV} or `binary`. */
|
|
16
|
+
export const DEFAULT_TALOSCTL_BINARY = 'talosctl';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Executable override, read at call time. The Mac PATH measured 2026-10-05 has v1.13.8; the
|
|
20
|
+
* cluster is v1.14.2. Set this to the v1.14.2 binary. A per-call `binary` option wins.
|
|
21
|
+
*/
|
|
22
|
+
export const TALOSCTL_BINARY_ENV = 'HF_TALOSCTL';
|
|
23
|
+
|
|
24
|
+
/** `bao` on `PATH` — the OpenBao CLI that fetches the cluster credentials at connect time. */
|
|
25
|
+
export const DEFAULT_BAO_BINARY = 'bao';
|
|
26
|
+
|
|
27
|
+
/** `bao` executable override, read at call time and vetted exactly like {@link TALOSCTL_BINARY_ENV}. */
|
|
28
|
+
export const BAO_BINARY_ENV = 'HF_BAO';
|
|
29
|
+
|
|
30
|
+
export class TalosBinaryRefused extends Error {
|
|
31
|
+
readonly command: string;
|
|
32
|
+
readonly binary: string;
|
|
33
|
+
|
|
34
|
+
constructor(command: string, detail: string, binary = DEFAULT_TALOSCTL_BINARY) {
|
|
35
|
+
super(`${binary} ${command} refused: ${detail}`);
|
|
36
|
+
this.name = 'TalosBinaryRefused';
|
|
37
|
+
this.command = command;
|
|
38
|
+
this.binary = binary;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* ⛔ THE DEFAULT IS VETTED TOO (hunt round 5): a bare `talosctl` is resolved against `PATH` here
|
|
44
|
+
* (not `Bun.which` — the published dist runs on Node, where that global is missing), symlinks are
|
|
45
|
+
* followed, and the REAL file is vetted, version-checked and executed by absolute path.
|
|
46
|
+
*/
|
|
47
|
+
export const resolveDefault = (binary: string) =>
|
|
48
|
+
Effect.try({
|
|
49
|
+
try: () => {
|
|
50
|
+
for (const entry of (process.env['PATH'] ?? '').split(delimiter)) {
|
|
51
|
+
if (!isAbsolute(entry)) continue;
|
|
52
|
+
try {
|
|
53
|
+
const candidate = join(entry, binary);
|
|
54
|
+
accessSync(candidate, constants.X_OK);
|
|
55
|
+
return realpathSync(candidate);
|
|
56
|
+
} catch {
|
|
57
|
+
// not in this PATH entry — try the next
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw new Error('not found');
|
|
61
|
+
},
|
|
62
|
+
catch: () => new TalosBinaryRefused('binary check', `${binary} was not found on PATH`, binary),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* ⛔ EVERY DIRECTORY ON THE CANONICAL PATH must belong to the current user or root and must not be
|
|
67
|
+
* group/world-writable (unless sticky: a sticky directory's entries cannot be renamed by others).
|
|
68
|
+
* Round 5 checked only the immediate parent's mode, so an attacker-owned 0755 parent, or a
|
|
69
|
+
* writable ancestor, could swap the file after the check. Returns the refusal reason, or
|
|
70
|
+
* undefined when the whole chain is trusted.
|
|
71
|
+
*/
|
|
72
|
+
const untrustedAncestor = (file: string, uid: number | undefined): string | undefined => {
|
|
73
|
+
let dir = realpathSync(dirname(file));
|
|
74
|
+
for (;;) {
|
|
75
|
+
const stat = lstatSync(dir);
|
|
76
|
+
// The boundary directory itself is still vetted; only what lies ABOVE it is not (test seam,
|
|
77
|
+
// trust-boundary.seam.ts, which the published tarball does not carry).
|
|
78
|
+
const isBoundary = isTrustedBoundary(dir);
|
|
79
|
+
if (uid !== undefined && stat.uid !== uid && stat.uid !== 0) {
|
|
80
|
+
return `lives under a directory owned by another user (${dir})`;
|
|
81
|
+
}
|
|
82
|
+
const sticky = (stat.mode & 0o1000) !== 0;
|
|
83
|
+
if ((stat.mode & 0o022) !== 0 && !sticky) {
|
|
84
|
+
return `lives in a group- or world-writable directory (${dir})`;
|
|
85
|
+
}
|
|
86
|
+
const up = dirname(dir);
|
|
87
|
+
if (up === dir || isBoundary) return undefined;
|
|
88
|
+
dir = up;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* ⛔ THE BINARY GETS THE VAULT-MINTED TALOSCONFIG: absolute, owned, not writable, executable, trusted
|
|
94
|
+
* path. ⛔ RETURNS THE CANONICAL PATH (round 7): the override's directory is resolved ONCE
|
|
95
|
+
* (realpath), that path and every ancestor of it are vetted, and the caller must launch exactly
|
|
96
|
+
* the returned path for the version probe AND the credential-bearing call. Vetting the given path
|
|
97
|
+
* and executing it again let a symlinked ancestor be repointed between the two launches. The leaf
|
|
98
|
+
* itself is still lstat'd, so a symlink leaf is refused rather than followed.
|
|
99
|
+
*/
|
|
100
|
+
export const checkBinaryPath = (binary: string, source: string) => {
|
|
101
|
+
const refuse = (why: string) =>
|
|
102
|
+
Effect.fail(new TalosBinaryRefused('binary check', `${source} ${why}`, binary));
|
|
103
|
+
if (!isAbsolute(binary)) return refuse('must be an absolute path');
|
|
104
|
+
return Effect.try({
|
|
105
|
+
try: () => {
|
|
106
|
+
const canonical = join(realpathSync(dirname(binary)), basename(binary));
|
|
107
|
+
const file = lstatSync(canonical);
|
|
108
|
+
const uid = process.getuid?.();
|
|
109
|
+
return { canonical, file, uid, ancestor: untrustedAncestor(canonical, uid) };
|
|
110
|
+
},
|
|
111
|
+
catch: () => new TalosBinaryRefused('binary check', `${source} is not a readable file`, binary),
|
|
112
|
+
}).pipe(
|
|
113
|
+
Effect.flatMap(({ canonical, file, uid, ancestor }) => {
|
|
114
|
+
// ★ lstat, not stat: a symlink can be re-pointed by whoever owns the link, so none is accepted.
|
|
115
|
+
if (file.isSymbolicLink()) return refuse('must not be a symlink');
|
|
116
|
+
if (!file.isFile()) return refuse('is not a regular file');
|
|
117
|
+
// ⛔ FAIL CLOSED: with no process uid (a platform lacking getuid) ownership cannot be checked.
|
|
118
|
+
if (uid === undefined) return refuse('cannot be vetted: no process uid to check ownership');
|
|
119
|
+
if (ancestor !== undefined) return refuse(ancestor);
|
|
120
|
+
if (uid !== undefined && file.uid !== uid && file.uid !== 0) {
|
|
121
|
+
return refuse('must be owned by the current user or root');
|
|
122
|
+
}
|
|
123
|
+
if ((file.mode & 0o022) !== 0) return refuse('is group- or world-writable');
|
|
124
|
+
try {
|
|
125
|
+
accessSync(canonical, constants.X_OK);
|
|
126
|
+
} catch {
|
|
127
|
+
return refuse('is not executable');
|
|
128
|
+
}
|
|
129
|
+
return Effect.succeed(canonical);
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve and vet the `bao` binary: {@link BAO_BINARY_ENV} wins over `bao` on `PATH`, and either is
|
|
136
|
+
* checked exactly like talosctl (absolute, owned, not writable, executable, not a symlink, every
|
|
137
|
+
* ancestor trusted). Returns the canonical path to launch.
|
|
138
|
+
*
|
|
139
|
+
* ⛔ `bao` FETCHES THE CLUSTER CREDENTIALS (`readKvValue` / `writeKvValue`), so an un-vetted `bao`
|
|
140
|
+
* on a group/world-writable PATH folder (umask 002, the round-10 incident) could answer with an
|
|
141
|
+
* attacker's kubeconfig. The same walk that vets `talosctl` must gate every `bao` launch.
|
|
142
|
+
*/
|
|
143
|
+
export const resolveBao = (): Effect.Effect<string, TalosBinaryRefused> => {
|
|
144
|
+
const override = process.env[BAO_BINARY_ENV]?.trim();
|
|
145
|
+
const requested = override !== undefined && override !== '' ? override : DEFAULT_BAO_BINARY;
|
|
146
|
+
const source = requested === DEFAULT_BAO_BINARY ? 'bao on PATH' : BAO_BINARY_ENV;
|
|
147
|
+
const resolved =
|
|
148
|
+
requested === DEFAULT_BAO_BINARY ? resolveDefault(requested) : Effect.succeed(requested);
|
|
149
|
+
return Effect.flatMap(resolved, (path) => checkBinaryPath(path, source));
|
|
150
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version probe's deadline against a REAL hanging child: the claim in talosctl.ts that a timed-out
|
|
3
|
+
* probe kills the child (and its process group) is proved here with `process.kill(pid, 0)`, not with a
|
|
4
|
+
* spawner that never starts anything. The script lives in a private os.tmpdir() directory removed on
|
|
5
|
+
* every exit path, and sleeps 60 s so a surviving child is unmistakable.
|
|
6
|
+
*/
|
|
7
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { afterAll, expect, test } from 'bun:test';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import * as NodeServices from '@effect/platform-node/NodeServices';
|
|
13
|
+
import { trustBoundaryForTests } from './trust-boundary.seam.ts';
|
|
14
|
+
import { TalosBinaryRefused, talosctl } from './talosctl.ts';
|
|
15
|
+
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), 'hf-probe-kill-'));
|
|
17
|
+
chmodSync(dir, 0o755);
|
|
18
|
+
trustBoundaryForTests(dir);
|
|
19
|
+
afterAll(() => rmSync(dir, { force: true, recursive: true }));
|
|
20
|
+
|
|
21
|
+
const pidFile = join(dir, 'pids');
|
|
22
|
+
const script = join(dir, 'talosctl');
|
|
23
|
+
// The shell and its `sleep` grandchild both record their pid: the grandchild only dies if the whole
|
|
24
|
+
// process group is killed, not just the direct child.
|
|
25
|
+
writeFileSync(script, `#!/bin/sh\nsleep 60 &\necho "$$ $!" > '${pidFile}'\nwait\n`, {
|
|
26
|
+
mode: 0o755,
|
|
27
|
+
});
|
|
28
|
+
chmodSync(script, 0o755);
|
|
29
|
+
|
|
30
|
+
const alive = (pid: number) => {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (cause) {
|
|
35
|
+
return (cause as NodeJS.ErrnoException).code !== 'ESRCH';
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
test('a probe that hangs is refused and its real child and process group are gone', async () => {
|
|
40
|
+
const outcome = await Effect.runPromise(
|
|
41
|
+
Effect.flip(
|
|
42
|
+
talosctl(['version'], {
|
|
43
|
+
binary: script,
|
|
44
|
+
talosconfigPath: 'unused.yaml',
|
|
45
|
+
versionProbeTimeout: '1 seconds',
|
|
46
|
+
}).pipe(Effect.provide(NodeServices.layer)),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
expect(outcome).toBeInstanceOf(TalosBinaryRefused);
|
|
50
|
+
const pids = readFileSync(pidFile, 'utf8').trim().split(' ').map(Number);
|
|
51
|
+
expect(pids).toHaveLength(2);
|
|
52
|
+
// SIGKILL follows SIGTERM after `forceKillAfter` (1 s), so allow it to land.
|
|
53
|
+
const deadline = Date.now() + 5000;
|
|
54
|
+
while (pids.some(alive) && Date.now() < deadline) await Bun.sleep(100);
|
|
55
|
+
expect(pids.map(alive)).toEqual([false, false]);
|
|
56
|
+
});
|