@homeflare/alchemy 0.47.1 → 0.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/talos/bao-env.d.ts +7 -0
  2. package/dist/talos/bao-env.d.ts.map +1 -0
  3. package/dist/talos/cluster-adapter-errors.d.ts +134 -0
  4. package/dist/talos/cluster-adapter-errors.d.ts.map +1 -0
  5. package/dist/talos/cluster-adapter.d.ts +116 -0
  6. package/dist/talos/cluster-adapter.d.ts.map +1 -0
  7. package/dist/talos/cluster-identity.d.ts +28 -0
  8. package/dist/talos/cluster-identity.d.ts.map +1 -0
  9. package/dist/talos/cluster-transport.d.ts +64 -0
  10. package/dist/talos/cluster-transport.d.ts.map +1 -0
  11. package/dist/talos/credentials-write.d.ts.map +1 -1
  12. package/dist/talos/credentials.d.ts +10 -19
  13. package/dist/talos/credentials.d.ts.map +1 -1
  14. package/dist/talos/fake-apiserver.d.ts +9 -0
  15. package/dist/talos/fake-apiserver.d.ts.map +1 -0
  16. package/dist/talos/index.d.ts +7 -0
  17. package/dist/talos/index.d.ts.map +1 -1
  18. package/dist/talos/index.js +683 -170
  19. package/dist/talos/index.js.map +22 -10
  20. package/dist/talos/kubeconfig-attrs.d.ts +14 -0
  21. package/dist/talos/kubeconfig-attrs.d.ts.map +1 -1
  22. package/dist/talos/kubeconfig-doc.d.ts +23 -0
  23. package/dist/talos/kubeconfig-doc.d.ts.map +1 -0
  24. package/dist/talos/kubeconfig.d.ts +0 -12
  25. package/dist/talos/kubeconfig.d.ts.map +1 -1
  26. package/dist/talos/literal-connection.d.ts +16 -0
  27. package/dist/talos/literal-connection.d.ts.map +1 -0
  28. package/dist/talos/machine-config-read.d.ts +1 -1
  29. package/dist/talos/machine-config-read.d.ts.map +1 -1
  30. package/dist/talos/mint-temp-file.d.ts +20 -0
  31. package/dist/talos/mint-temp-file.d.ts.map +1 -0
  32. package/dist/talos/node-temp.harness.d.ts +2 -0
  33. package/dist/talos/node-temp.harness.d.ts.map +1 -0
  34. package/dist/talos/talos-bootstrap-etcd.d.ts +1 -1
  35. package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -1
  36. package/dist/talos/talos-cluster-health.d.ts +15 -15
  37. package/dist/talos/talos-cluster-health.d.ts.map +1 -1
  38. package/dist/talos/talos-cluster-identity.d.ts +65 -0
  39. package/dist/talos/talos-cluster-identity.d.ts.map +1 -0
  40. package/dist/talos/talosctl-binary.d.ts +43 -0
  41. package/dist/talos/talosctl-binary.d.ts.map +1 -0
  42. package/dist/talos/talosctl.d.ts +29 -3
  43. package/dist/talos/talosctl.d.ts.map +1 -1
  44. package/dist/talos/trust-boundary.d.ts +8 -0
  45. package/dist/talos/trust-boundary.d.ts.map +1 -0
  46. package/dist/talos/values.d.ts.map +1 -1
  47. package/dist/talos/yaml-parse.d.ts +3 -0
  48. package/dist/talos/yaml-parse.d.ts.map +1 -0
  49. package/dist/verify/fake-engine.d.ts +2 -0
  50. package/dist/verify/fake-engine.d.ts.map +1 -1
  51. package/docs/talos-openbao-adapter.md +45 -0
  52. package/docs/upstream-conformance-bun.md +61 -24
  53. package/package.json +6 -2
  54. package/src/talos/bao-env.ts +16 -0
  55. package/src/talos/bao-vetting.test.ts +97 -0
  56. package/src/talos/cluster-adapter-deadline.test.ts +110 -0
  57. package/src/talos/cluster-adapter-errors.ts +193 -0
  58. package/src/talos/cluster-adapter-move.test.ts +131 -0
  59. package/src/talos/cluster-adapter-reconcile.test.ts +165 -0
  60. package/src/talos/cluster-adapter-refusals.test.ts +102 -0
  61. package/src/talos/cluster-adapter-rename.test.ts +87 -0
  62. package/src/talos/cluster-adapter-secrecy.test.ts +243 -0
  63. package/src/talos/cluster-adapter.fixtures.ts +125 -0
  64. package/src/talos/cluster-adapter.test.ts +240 -0
  65. package/src/talos/cluster-adapter.ts +250 -0
  66. package/src/talos/cluster-identity.ts +63 -0
  67. package/src/talos/cluster-transport.ts +110 -0
  68. package/src/talos/credentials-write.ts +27 -5
  69. package/src/talos/credentials.ts +51 -45
  70. package/src/talos/fake-apiserver.ts +55 -0
  71. package/src/talos/fake-process.ts +47 -1
  72. package/src/talos/index.ts +32 -0
  73. package/src/talos/kubeconfig-adapter.test.ts +37 -0
  74. package/src/talos/kubeconfig-attrs.ts +12 -28
  75. package/src/talos/kubeconfig-doc.ts +74 -0
  76. package/src/talos/kubeconfig.fixtures.ts +52 -0
  77. package/src/talos/kubeconfig.test.ts +6 -54
  78. package/src/talos/kubeconfig.ts +18 -34
  79. package/src/talos/literal-connection.ts +59 -0
  80. package/src/talos/mint-temp-file.test.ts +44 -0
  81. package/src/talos/mint-temp-file.ts +68 -0
  82. package/src/talos/node-connect.harness.ts +78 -0
  83. package/src/talos/node-connect.test.ts +27 -0
  84. package/src/talos/node-temp.harness.ts +40 -0
  85. package/src/talos/node-temp.test.ts +26 -0
  86. package/src/talos/talos-cluster-health-declare.test.ts +54 -0
  87. package/src/talos/talos-cluster-health-literal.test.ts +69 -0
  88. package/src/talos/talos-cluster-health.test.ts +104 -18
  89. package/src/talos/talos-cluster-health.ts +46 -14
  90. package/src/talos/talos-cluster-identity.test.ts +96 -0
  91. package/src/talos/talos-cluster-identity.ts +130 -0
  92. package/src/talos/talosctl-binary.ts +150 -0
  93. package/src/talos/talosctl-probe-kill.test.ts +56 -0
  94. package/src/talos/talosctl-vetting.test.ts +212 -0
  95. package/src/talos/talosctl.test.ts +163 -0
  96. package/src/talos/talosctl.ts +133 -15
  97. package/src/talos/trust-boundary.test.ts +17 -0
  98. package/src/talos/trust-boundary.ts +45 -0
  99. package/src/talos/values.ts +5 -12
  100. package/src/talos/yaml-parse.test.ts +41 -0
  101. package/src/talos/yaml-parse.ts +28 -0
  102. package/src/verify/fake-engine.ts +10 -0
  103. package/dist/talos/fake-process.d.ts +0 -56
  104. package/dist/talos/fake-process.d.ts.map +0 -1
  105. package/dist/talos/machine-config-fixtures.d.ts +0 -60
  106. package/dist/talos/machine-config-fixtures.d.ts.map +0 -1
  107. package/src/postgres/fixtures/pg-REL_18_6-acl.h.txt +0 -147
  108. package/src/postgres/fixtures/pg-REL_18_6-aclexplode-func.txt +0 -31
  109. package/src/postgres/fixtures/pg-REL_18_6-alter-default-privileges-synopsis.txt +0 -82
  110. package/src/postgres/fixtures/pg-REL_18_6-alter_role.sgml.txt +0 -44
  111. package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +0 -26
  112. package/src/postgres/fixtures/pg-REL_18_6-create_role.sgml.txt +0 -26
  113. package/src/postgres/fixtures/pg-REL_18_6-grant-synopsis.txt +0 -95
  114. package/src/postgres/fixtures/pg-REL_18_6-pg_attribute.h.txt +0 -24
  115. package/src/postgres/fixtures/pg-REL_18_6-pg_auth_members.h.txt +0 -62
  116. package/src/postgres/fixtures/pg-REL_18_6-pg_authid.h.txt +0 -70
  117. package/src/postgres/fixtures/pg-REL_18_6-pg_class.h.txt +0 -33
  118. package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +0 -67
  119. package/src/postgres/fixtures/pg-REL_18_6-pg_default_acl.h.txt +0 -81
  120. package/src/postgres/fixtures/pg-REL_18_6-pg_namespace.h.txt +0 -28
  121. package/src/postgres/fixtures/pg-REL_18_6-revoke-synopsis.txt +0 -124
  122. package/src/release/fixtures/openbao/checksums.txt +0 -83
  123. package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
  124. package/src/release/fixtures/openbao/darwin-assets.txt +0 -12
  125. package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +0 -109
  126. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +0 -22
  127. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +0 -2
  128. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +0 -2
  129. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +0 -2
  130. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +0 -8
@@ -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
- export const TalosClusterHealth = Resource<TalosClusterHealth>('Talos.ClusterHealth');
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) return { action: 'noop' } as const;
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
+ });