@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,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Kubernetes.ClusterAdapter` kind `talos-openbao`.
|
|
3
|
+
*
|
|
4
|
+
* Reads `mount/key` from OpenBao at connect time (`credentials.ts`'s `readKvValue`, which
|
|
5
|
+
* shells to `bao` with BAO_ADDR/BAO_TOKEN) and returns a `ClusterTransport`.
|
|
6
|
+
* ⛔ NOTHING IS WRITTEN TO DISK. ⛔ THE PERSISTED CONNECTION IS `{ kind, uid }` ONLY.
|
|
7
|
+
* alchemy `Kubernetes/Connection.ts` (v2.0.0-beta.79, lines 12-14) stores the Connection on
|
|
8
|
+
* every workload's attributes, so the upstream `client-cert` kind would put the admin PEM in
|
|
9
|
+
* the shared state store. The stock `kubeconfig` kind is also refused: an empty path falls
|
|
10
|
+
* back to `$KUBECONFIG`.
|
|
11
|
+
* ⛔ mount/key/context LIVE IN THE ADAPTER'S CONFIGURATION, NOT IN THE AUTH BLOCK. Upstream
|
|
12
|
+
* compares clusters by the auth block (`internal/workload.ts:91-96` `connectionIdentity`) and
|
|
13
|
+
* HelmChart/Manifest answer `replace` when it changes (`HelmChart.ts:248-258`,
|
|
14
|
+
* `Manifest.ts:194-209`); a replace creates first, then cleanup deletes the SAME-named objects.
|
|
15
|
+
* Renaming a vault key would silently delete Cilium, External Secrets and Gatekeeper.
|
|
16
|
+
* ⛔ THE IDENTITY RULE: THE AUTH BLOCK NAMES THE PHYSICAL CLUSTER. `uid` is the kube-system
|
|
17
|
+
* Namespace `metadata.uid` (public, stable across CA rotation). read/update/delete all
|
|
18
|
+
* reconnect from the SAVED auth block (`Manifest.ts:214` and `:267`, `HelmChart.ts:322` and
|
|
19
|
+
* `:335`, via `connectionOfOutput`, `internal/workload.ts:104-108`). A cluster NAME resolved
|
|
20
|
+
* through today's MUTABLE config would let a repointed `c1` PATCH one cluster and DELETE on
|
|
21
|
+
* another (reproduced through ManifestProvider). So the auth block carries the uid and NOTHING
|
|
22
|
+
* else: the config entry is looked up BY uid (no probing walk), `connect` fails closed BEFORE
|
|
23
|
+
* any vault read when the uid is absent or the row is legacy (`auth.cluster`), then GETs
|
|
24
|
+
* kube-system and compares before returning a transport. Missing, unreadable, unknown or
|
|
25
|
+
* different uid is a typed error — never `ClusterNotFoundError` (except a `retired` entry's
|
|
26
|
+
* absent key), which upstream turns into a silent no-op. Cost: one extra GET per connect.
|
|
27
|
+
* ⛔ BOOTSTRAP-THEN-PIN. A brand-new cluster has no uid until it exists, and an Output uid is
|
|
28
|
+
* forbidden (`talosOpenBaoConnection`). So: (1) the first deploy creates the cluster and
|
|
29
|
+
* `Talos.ClusterIdentity` (which reads and outputs the uid) with NO workloads; (2) the operator
|
|
30
|
+
* pins that printed uid literal in this adapter's config; (3) workloads deploy afterwards with
|
|
31
|
+
* `talosOpenBaoConnection(<that literal>)`. A uid that is not pinned is refused at connect.
|
|
32
|
+
* ⚠️ A REAL MOVE IS NOT AN AUTOMATIC REPLACE. `Talos.ClusterIdentity` refuses a changed uid
|
|
33
|
+
* (`TalosClusterMoved`); a move is a NEW identity resource per physical cluster, so the old
|
|
34
|
+
* rows keep their old uid and their cleanup reaches only the old cluster.
|
|
35
|
+
* ⛔ ONE 10 s DEADLINE covers the vault read AND the uid GET (`withConnectDeadline`): a hung `bao`
|
|
36
|
+
* fails with `TalosOpenBaoConnectTimeout` and the `bao` child is killed. The uid GET has its own
|
|
37
|
+
* 5 s deadline inside it, so a silent apiserver fails first with `TalosClusterIdentityTimeout`.
|
|
38
|
+
* ⚠️ The in-flight HTTPS GET is NOT aborted: upstream `readObject` takes no signal
|
|
39
|
+
* (`internal/client.ts:75`), so the socket lives until the OS gives up. Left for the upstream ask.
|
|
40
|
+
*
|
|
41
|
+
* USAGE RULES (upstream behaviour this adapter cannot fix; full text in
|
|
42
|
+
* `docs/talos-openbao-adapter.md`):
|
|
43
|
+
* 1. Move a workload to another cluster ONLY by new logical IDs (destroy + create), never by
|
|
44
|
+
* changing its connection in place.
|
|
45
|
+
* 2. Manage namespaces as separate `Manifest`s, never HelmChart `createNamespace`.
|
|
46
|
+
* 3. Never rename a HelmChart `releaseName` in place.
|
|
47
|
+
*
|
|
48
|
+
* Walked against alchemy 2.0.0-beta.79 `ClusterAdapter.ts` / `BuiltinAdapters.ts` and the OpenBao
|
|
49
|
+
* v2.6.2 absence string in `isVaultKeyAbsent`. Fake `bao` and fake apiserver only.
|
|
50
|
+
*/
|
|
51
|
+
import {
|
|
52
|
+
ClusterAdapter,
|
|
53
|
+
type ClusterAdapterService,
|
|
54
|
+
ClusterNotFoundError,
|
|
55
|
+
type ClusterTransport,
|
|
56
|
+
} from 'alchemy/Kubernetes/ClusterAdapter';
|
|
57
|
+
import type { Connection } from 'alchemy/Kubernetes/Connection';
|
|
58
|
+
import type * as Duration from 'effect/Duration';
|
|
59
|
+
import * as Effect from 'effect/Effect';
|
|
60
|
+
import * as Layer from 'effect/Layer';
|
|
61
|
+
import type * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
62
|
+
import {
|
|
63
|
+
type TalosClusterIdentityMismatch,
|
|
64
|
+
TalosClusterIdentityMissing,
|
|
65
|
+
type TalosClusterIdentityTimeout,
|
|
66
|
+
type TalosClusterIdentityUnreadable,
|
|
67
|
+
type TalosKubeconfigUnreadable,
|
|
68
|
+
TalosOpenBaoAmbiguousUid,
|
|
69
|
+
TalosOpenBaoAuthKind,
|
|
70
|
+
type TalosOpenBaoConnectTimeout,
|
|
71
|
+
TalosOpenBaoLegacyAuth,
|
|
72
|
+
TalosOpenBaoUnknownCluster,
|
|
73
|
+
TalosUidNotLiteral,
|
|
74
|
+
type TalosVaultKeyMissing,
|
|
75
|
+
} from './cluster-adapter-errors.ts';
|
|
76
|
+
import { assertClusterUid } from './cluster-identity.ts';
|
|
77
|
+
import {
|
|
78
|
+
CONNECT_TIMEOUT,
|
|
79
|
+
type TalosOpenBaoCluster,
|
|
80
|
+
openTransport,
|
|
81
|
+
withConnectDeadline,
|
|
82
|
+
} from './cluster-transport.ts';
|
|
83
|
+
|
|
84
|
+
export {
|
|
85
|
+
TalosClusterIdentityMismatch,
|
|
86
|
+
TalosClusterIdentityMissing,
|
|
87
|
+
TalosClusterIdentityTimeout,
|
|
88
|
+
TalosClusterIdentityUnreadable,
|
|
89
|
+
TalosClusterMoved,
|
|
90
|
+
TalosKubeconfigUnreadable,
|
|
91
|
+
TalosOpenBaoAmbiguousUid,
|
|
92
|
+
TalosOpenBaoAuthKind,
|
|
93
|
+
TalosOpenBaoConnectTimeout,
|
|
94
|
+
TalosOpenBaoLegacyAuth,
|
|
95
|
+
TalosOpenBaoUnknownCluster,
|
|
96
|
+
TalosUidNotLiteral,
|
|
97
|
+
TalosVaultKeyMissing,
|
|
98
|
+
} from './cluster-adapter-errors.ts';
|
|
99
|
+
export { type TalosOpenBaoCluster, talosOpenBaoCluster } from './cluster-transport.ts';
|
|
100
|
+
|
|
101
|
+
declare module 'alchemy/Kubernetes/Connection' {
|
|
102
|
+
interface AuthRegistry {
|
|
103
|
+
/**
|
|
104
|
+
* Admin kubeconfig in OpenBao KV, read when a workload connects. Contributed by
|
|
105
|
+
* {@link TalosOpenBaoAdapter}, which holds the mount, key and context.
|
|
106
|
+
*/
|
|
107
|
+
'talos-openbao': {
|
|
108
|
+
/**
|
|
109
|
+
* The cluster's kube-system `metadata.uid`: the ONLY identity, a LITERAL string known at
|
|
110
|
+
* plan time (never an Output, see {@link talosOpenBaoConnection}). A row saved before it
|
|
111
|
+
* existed has none at runtime and is refused at connect.
|
|
112
|
+
*/
|
|
113
|
+
uid: string;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* ⛔ KEYED BY ALIAS, BUT THE ALIAS IS NEVER PERSISTED OR LOOKED UP BY. `connect` finds the entry
|
|
120
|
+
* whose pinned `uid` equals the saved auth block's, so renaming an alias changes nothing upstream
|
|
121
|
+
* hashes. Unknown uid is {@link TalosOpenBaoUnknownCluster}: refused, not another cluster's key.
|
|
122
|
+
*/
|
|
123
|
+
export type TalosOpenBaoConfig = Readonly<Record<string, TalosOpenBaoCluster>>;
|
|
124
|
+
|
|
125
|
+
/** A `Connection` narrowed to this kind, so a PEM-bearing connection cannot be stored by type. */
|
|
126
|
+
export type TalosOpenBaoConnection = Connection & {
|
|
127
|
+
readonly auth: { readonly kind: 'talos-openbao'; readonly uid: string };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Serializable connection: kind and uid. No alias, vault path, endpoint, CA or cert.
|
|
132
|
+
* ⛔ THE UID MUST BE A LITERAL. An Output (a resource attribute such as `Talos.ClusterIdentity`'s)
|
|
133
|
+
* is unresolved at plan time, so `isResolved(news)` is false, upstream plans an UPDATE (Plan.ts)
|
|
134
|
+
* and reconcile replays the OLD cluster's `previousObjects` as deletes on the NEW cluster
|
|
135
|
+
* (reproduced: DELETE on B). Take the uid from the pinned adapter entry: a changed literal is a
|
|
136
|
+
* changed auth block, which upstream answers with `replace`. Anything else throws
|
|
137
|
+
* {@link TalosUidNotLiteral} while the stack is being declared.
|
|
138
|
+
*/
|
|
139
|
+
export const talosOpenBaoConnection = (uid: string): TalosOpenBaoConnection => {
|
|
140
|
+
if (typeof uid !== 'string' || uid === '') throw new TalosUidNotLiteral({});
|
|
141
|
+
return { auth: { kind: 'talos-openbao', uid } };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve one `talos-openbao` connection to a transport. Requires `ChildProcessSpawner`
|
|
146
|
+
* because the vault read shells to `bao`, and reaches the apiserver once for the identity check.
|
|
147
|
+
*
|
|
148
|
+
* ⚠️ A key the vault reports absent is `ClusterNotFoundError` ONLY for a cluster configured
|
|
149
|
+
* `retired: true` (upstream `read`/`delete` treat that as "everything in-cluster is already
|
|
150
|
+
* gone", so tearing down a destroyed cluster is not stuck). Otherwise it is the typed
|
|
151
|
+
* `TalosVaultKeyMissing`. Neither message names document bytes.
|
|
152
|
+
*/
|
|
153
|
+
export const connectTalosOpenBao = (
|
|
154
|
+
configs: TalosOpenBaoConfig,
|
|
155
|
+
connection: Connection,
|
|
156
|
+
timeout: Duration.Duration = CONNECT_TIMEOUT,
|
|
157
|
+
): Effect.Effect<
|
|
158
|
+
ClusterTransport,
|
|
159
|
+
| TalosOpenBaoAuthKind
|
|
160
|
+
| TalosOpenBaoUnknownCluster
|
|
161
|
+
| TalosOpenBaoAmbiguousUid
|
|
162
|
+
| TalosOpenBaoLegacyAuth
|
|
163
|
+
| TalosClusterIdentityMissing
|
|
164
|
+
| TalosClusterIdentityMismatch
|
|
165
|
+
| TalosClusterIdentityUnreadable
|
|
166
|
+
| TalosClusterIdentityTimeout
|
|
167
|
+
| TalosOpenBaoConnectTimeout
|
|
168
|
+
| TalosVaultKeyMissing
|
|
169
|
+
| ClusterNotFoundError
|
|
170
|
+
| TalosKubeconfigUnreadable
|
|
171
|
+
| Error,
|
|
172
|
+
ChildProcessSpawner.ChildProcessSpawner
|
|
173
|
+
> =>
|
|
174
|
+
Effect.gen(function* () {
|
|
175
|
+
// ⛔ FAIL CLOSED BEFORE ANY DESTRUCTURING: a saved row missing its whole connection, or just
|
|
176
|
+
// its auth block, names no physical cluster — a typed refusal, never a raw TypeError.
|
|
177
|
+
if (connection === undefined || connection === null) {
|
|
178
|
+
return yield* Effect.fail(new TalosClusterIdentityMissing({}));
|
|
179
|
+
}
|
|
180
|
+
const { auth } = connection;
|
|
181
|
+
if (auth === undefined || auth === null) {
|
|
182
|
+
return yield* Effect.fail(new TalosClusterIdentityMissing({}));
|
|
183
|
+
}
|
|
184
|
+
if (auth.kind !== 'talos-openbao') {
|
|
185
|
+
return yield* Effect.fail(new TalosOpenBaoAuthKind({ kind: auth.kind }));
|
|
186
|
+
}
|
|
187
|
+
// ⛔ Before the vault is touched. A row saved with an alias is refused even if it also has a
|
|
188
|
+
// uid: no silent migration, the saved state is edited deliberately (the error says how).
|
|
189
|
+
if (Object.hasOwn(auth, 'cluster')) {
|
|
190
|
+
return yield* Effect.fail(new TalosOpenBaoLegacyAuth({}));
|
|
191
|
+
}
|
|
192
|
+
const { uid } = auth;
|
|
193
|
+
if (typeof uid !== 'string' || uid === '') {
|
|
194
|
+
return yield* Effect.fail(new TalosClusterIdentityMissing({}));
|
|
195
|
+
}
|
|
196
|
+
const matches = Object.entries(configs).filter(([, entry]) => entry.uid === uid);
|
|
197
|
+
const [match] = matches;
|
|
198
|
+
if (match === undefined) {
|
|
199
|
+
return yield* Effect.fail(
|
|
200
|
+
new TalosOpenBaoUnknownCluster({
|
|
201
|
+
known: Object.values(configs).map((entry) => entry.uid),
|
|
202
|
+
uid,
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
// ⛔ Two entries claiming one uid is a configuration error: refuse, never pick one.
|
|
207
|
+
if (matches.length > 1) {
|
|
208
|
+
return yield* Effect.fail(
|
|
209
|
+
new TalosOpenBaoAmbiguousUid({ aliases: matches.map(([alias]) => alias), uid }),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const [alias, config] = match;
|
|
213
|
+
// ⛔ A retired pin means the cluster is gone: no vault read, so destroying its rows completes.
|
|
214
|
+
if (config.retired === true) {
|
|
215
|
+
return yield* Effect.fail(
|
|
216
|
+
new ClusterNotFoundError({ message: `talos-openbao cluster '${alias}' is retired` }),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
// ⛔ One deadline over the vault read AND the uid GET (`withConnectDeadline`).
|
|
220
|
+
return yield* withConnectDeadline(
|
|
221
|
+
alias,
|
|
222
|
+
Effect.gen(function* () {
|
|
223
|
+
const transport = yield* openTransport(config);
|
|
224
|
+
yield* assertClusterUid(alias, uid, transport);
|
|
225
|
+
return transport;
|
|
226
|
+
}),
|
|
227
|
+
timeout,
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Register kind `talos-openbao`. Merge with `Kubernetes.providers()`:
|
|
233
|
+
* `Layer.mergeAll(Kubernetes.providers(), TalosOpenBaoAdapter({ c1: talosOpenBaoCluster(kc1) }))`
|
|
234
|
+
* where `kc1` is the props object handed to `Talos.Kubeconfig`.
|
|
235
|
+
*/
|
|
236
|
+
export const TalosOpenBaoAdapter = (
|
|
237
|
+
config: TalosOpenBaoConfig,
|
|
238
|
+
): Layer.Layer<ClusterAdapterService, never, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
239
|
+
Layer.effect(
|
|
240
|
+
ClusterAdapter('talos-openbao'),
|
|
241
|
+
Effect.gen(function* () {
|
|
242
|
+
const context = yield* Effect.context<ChildProcessSpawner.ChildProcessSpawner>();
|
|
243
|
+
const service: ClusterAdapterService = {
|
|
244
|
+
kind: 'Kubernetes.ClusterAdapter',
|
|
245
|
+
connect: (connection) =>
|
|
246
|
+
connectTalosOpenBao(config, connection).pipe(Effect.provideContext(context)),
|
|
247
|
+
};
|
|
248
|
+
return service;
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which PHYSICAL cluster answers — the kube-system Namespace's `metadata.uid`.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY THE UID. It is public, assigned once at cluster creation and stable across CA and
|
|
5
|
+
* admin-cert rotation (a CA fingerprint is not: rotation would read as "a different cluster").
|
|
6
|
+
* Every cluster has the namespace, and the admin kubeconfig can read it.
|
|
7
|
+
* ★ The GET goes through alchemy's own `readObject` (`Kubernetes/internal/client.ts`, reached
|
|
8
|
+
* through the package's `./*` export) so TLS, CA and client-cert handling stay upstream's.
|
|
9
|
+
*/
|
|
10
|
+
import type { ClusterTransport } from 'alchemy/Kubernetes/ClusterAdapter';
|
|
11
|
+
import { readObject } from 'alchemy/Kubernetes/internal/client';
|
|
12
|
+
import * as Duration from 'effect/Duration';
|
|
13
|
+
import * as Effect from 'effect/Effect';
|
|
14
|
+
import {
|
|
15
|
+
TalosClusterIdentityMismatch,
|
|
16
|
+
TalosClusterIdentityTimeout,
|
|
17
|
+
TalosClusterIdentityUnreadable,
|
|
18
|
+
} from './cluster-adapter-errors.ts';
|
|
19
|
+
|
|
20
|
+
const KUBE_SYSTEM = { apiVersion: 'v1', kind: 'Namespace', name: 'kube-system' } as const;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ⛔ upstream `readObject` (`internal/client.ts`) sets no request timeout, so a silent apiserver
|
|
24
|
+
* would hang connect and every plan. Bounded here; the failure is typed and closed.
|
|
25
|
+
* ⛔ SHORTER THAN `CONNECT_TIMEOUT` (10 s, cluster-transport.ts): the outer deadline wraps the vault
|
|
26
|
+
* read AND this uid GET together, so an equal inner deadline could never fire first and
|
|
27
|
+
* `TalosClusterIdentityTimeout` was unreachable through `connectTalosOpenBao` (red team, PR 355).
|
|
28
|
+
* A silent apiserver now fails with the identity timeout while a slow `bao` still gets the full
|
|
29
|
+
* outer budget.
|
|
30
|
+
*/
|
|
31
|
+
export const UID_READ_TIMEOUT = Duration.seconds(5);
|
|
32
|
+
|
|
33
|
+
/** The cluster's kube-system uid. Request failures propagate: nothing here fails open. */
|
|
34
|
+
export const readClusterUid = (
|
|
35
|
+
cluster: string,
|
|
36
|
+
transport: ClusterTransport,
|
|
37
|
+
timeout: Duration.Duration = UID_READ_TIMEOUT,
|
|
38
|
+
) =>
|
|
39
|
+
readObject({ object: KUBE_SYSTEM, transport }).pipe(
|
|
40
|
+
Effect.timeoutOrElse({
|
|
41
|
+
duration: timeout,
|
|
42
|
+
orElse: () =>
|
|
43
|
+
Effect.fail(
|
|
44
|
+
new TalosClusterIdentityTimeout({ cluster, seconds: Duration.toSeconds(timeout) }),
|
|
45
|
+
),
|
|
46
|
+
}),
|
|
47
|
+
Effect.flatMap((body) => {
|
|
48
|
+
const uid = (body as { metadata?: { uid?: unknown } } | undefined)?.metadata?.uid;
|
|
49
|
+
return typeof uid === 'string' && uid !== ''
|
|
50
|
+
? Effect.succeed(uid)
|
|
51
|
+
: Effect.fail(new TalosClusterIdentityUnreadable({ cluster }));
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/** Succeeds only when the answering cluster is the one `expected` names. */
|
|
56
|
+
export const assertClusterUid = (cluster: string, expected: string, transport: ClusterTransport) =>
|
|
57
|
+
readClusterUid(cluster, transport).pipe(
|
|
58
|
+
Effect.flatMap((got) =>
|
|
59
|
+
got === expected
|
|
60
|
+
? Effect.void
|
|
61
|
+
: Effect.fail(new TalosClusterIdentityMismatch({ cluster, expected, got })),
|
|
62
|
+
),
|
|
63
|
+
);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One cluster's vault location and the in-memory read of its admin kubeconfig, shared by the
|
|
3
|
+
* `talos-openbao` adapter (which then verifies identity) and `Talos.ClusterIdentity` (which
|
|
4
|
+
* reads the uid the adapter will later demand).
|
|
5
|
+
*/
|
|
6
|
+
import type { ClusterTransport } from 'alchemy/Kubernetes/ClusterAdapter';
|
|
7
|
+
import * as Duration from 'effect/Duration';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import {
|
|
10
|
+
TalosKubeconfigUnreadable,
|
|
11
|
+
TalosOpenBaoConnectTimeout,
|
|
12
|
+
TalosVaultKeyMissing,
|
|
13
|
+
} from './cluster-adapter-errors.ts';
|
|
14
|
+
import { DEFAULT_KUBECONFIG_KEY, isVaultKeyAbsent, readKvValue } from './credentials.ts';
|
|
15
|
+
import { kubeconfigTransport } from './kubeconfig-doc.ts';
|
|
16
|
+
import type { KubeconfigProps } from './kubeconfig.ts';
|
|
17
|
+
|
|
18
|
+
/** Where one cluster's admin kubeconfig lives in the vault. Configuration; never persisted. */
|
|
19
|
+
export type TalosVaultLocation = {
|
|
20
|
+
/** OpenBao KV mount, e.g. `talos-c1`. */
|
|
21
|
+
readonly mount: string;
|
|
22
|
+
/** Path under the mount. `Talos.Kubeconfig` writes `kubeconfig` unless `kubeconfigKey` is set. */
|
|
23
|
+
readonly key: string;
|
|
24
|
+
/** Context name inside the kubeconfig. Not secret. */
|
|
25
|
+
readonly context: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One adapter entry: a vault location PINNED to the physical cluster it must answer as.
|
|
30
|
+
* ⛔ `uid` is the kube-system `metadata.uid`. The persisted auth block carries only this, and
|
|
31
|
+
* connect looks the entry up BY it, so renaming the alias, vault key, mount or context changes
|
|
32
|
+
* nothing upstream hashes (`internal/workload.ts:91-96` `connectionIdentity`). Connect still
|
|
33
|
+
* proves the answering cluster has this uid before it returns a transport.
|
|
34
|
+
*/
|
|
35
|
+
export type TalosOpenBaoCluster = TalosVaultLocation & {
|
|
36
|
+
readonly uid: string;
|
|
37
|
+
/**
|
|
38
|
+
* ⛔ ONLY `true` MAKES THIS UID MEAN "THE CLUSTER IS GONE": connect answers `ClusterNotFoundError`
|
|
39
|
+
* (upstream read/delete treat it as "everything in-cluster is already gone") WITHOUT a vault
|
|
40
|
+
* read, so destroying a dead cluster's rows completes. Otherwise an absent key is the loud
|
|
41
|
+
* `TalosVaultKeyMissing`: a typo in mount/key must not make `destroy` a silent no-op.
|
|
42
|
+
*/
|
|
43
|
+
readonly retired?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* ★ ONE SOURCE OF TRUTH FOR mount/key/context: build the location from the SAME props the
|
|
48
|
+
* `Talos.Kubeconfig` resource writes with (`target.mount`, `kubeconfigKey`, `context`), so the
|
|
49
|
+
* writer and the reader cannot disagree.
|
|
50
|
+
*/
|
|
51
|
+
export const talosVaultLocation = (
|
|
52
|
+
props: Pick<KubeconfigProps, 'context' | 'kubeconfigKey' | 'target'>,
|
|
53
|
+
): TalosVaultLocation => ({
|
|
54
|
+
context: props.context,
|
|
55
|
+
key: props.kubeconfigKey ?? DEFAULT_KUBECONFIG_KEY,
|
|
56
|
+
mount: props.target.mount,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** An adapter entry: {@link talosVaultLocation} plus the uid it is pinned to. */
|
|
60
|
+
export const talosOpenBaoCluster = (
|
|
61
|
+
props: Pick<KubeconfigProps, 'context' | 'kubeconfigKey' | 'target'>,
|
|
62
|
+
pin: { readonly uid: string; readonly retired?: boolean },
|
|
63
|
+
): TalosOpenBaoCluster => ({
|
|
64
|
+
...talosVaultLocation(props),
|
|
65
|
+
uid: pin.uid,
|
|
66
|
+
...(pin.retired === true ? { retired: true } : {}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* ⛔ ONE DEADLINE FOR THE WHOLE CONNECT PATH. `bao kv get` has no timeout of its own, and the
|
|
71
|
+
* uid GET's bound (`cluster-identity.ts`) only starts after the vault read returns, so a hung
|
|
72
|
+
* `bao` hung connect and every plan. Interrupting the effect closes the spawn scope, which
|
|
73
|
+
* signals the child's process group (`NodeChildProcessSpawner` `terminateProcessGroup`).
|
|
74
|
+
*/
|
|
75
|
+
export const CONNECT_TIMEOUT = Duration.seconds(10);
|
|
76
|
+
|
|
77
|
+
export const withConnectDeadline = <A, E, R>(
|
|
78
|
+
cluster: string,
|
|
79
|
+
effect: Effect.Effect<A, E, R>,
|
|
80
|
+
timeout: Duration.Duration = CONNECT_TIMEOUT,
|
|
81
|
+
) =>
|
|
82
|
+
effect.pipe(
|
|
83
|
+
Effect.timeoutOrElse({
|
|
84
|
+
duration: timeout,
|
|
85
|
+
orElse: () =>
|
|
86
|
+
Effect.fail(
|
|
87
|
+
new TalosOpenBaoConnectTimeout({ cluster, seconds: Duration.toSeconds(timeout) }),
|
|
88
|
+
),
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Vault read and parse, in memory. ⛔ No identity check here: callers decide what to trust.
|
|
94
|
+
* ⚠️ Only a document that does not parse is `TalosKubeconfigUnreadable`; a parser or runtime
|
|
95
|
+
* failure (a missing global, a broken import) is a defect and propagates as one.
|
|
96
|
+
*/
|
|
97
|
+
export const openTransport = (config: TalosVaultLocation) =>
|
|
98
|
+
Effect.gen(function* () {
|
|
99
|
+
const { context, key, mount } = config;
|
|
100
|
+
const raw = yield* readKvValue(mount, key, ['kubeconfig', 'config']).pipe(
|
|
101
|
+
Effect.mapError((error) =>
|
|
102
|
+
isVaultKeyAbsent(error) ? new TalosVaultKeyMissing({ key, mount }) : error,
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
const material = yield* Effect.sync(() => kubeconfigTransport(raw, context));
|
|
106
|
+
if (material === undefined) {
|
|
107
|
+
return yield* Effect.fail(new TalosKubeconfigUnreadable({ context, key, mount }));
|
|
108
|
+
}
|
|
109
|
+
return { ...material, headers: Effect.succeed({}) } satisfies ClusterTransport;
|
|
110
|
+
});
|
|
@@ -13,9 +13,16 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as Effect from 'effect/Effect';
|
|
15
15
|
import type * as Scope from 'effect/Scope';
|
|
16
|
+
import * as Duration from 'effect/Duration';
|
|
16
17
|
import * as Stream from 'effect/Stream';
|
|
18
|
+
import { unlink } from 'node:fs/promises';
|
|
19
|
+
import { randomUUID } from 'node:crypto';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
17
22
|
import * as ChildProcess from 'effect/process/ChildProcess';
|
|
18
23
|
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
24
|
+
import { baoEnv } from './bao-env.ts';
|
|
25
|
+
import { resolveBao } from './talosctl-binary.ts';
|
|
19
26
|
|
|
20
27
|
/**
|
|
21
28
|
* A fresh, unguessable temp path for SOME OTHER PROCESS (talosctl) to create — unlike
|
|
@@ -29,9 +36,9 @@ export const reservedTempPath = (
|
|
|
29
36
|
label: string,
|
|
30
37
|
): Effect.Effect<{ readonly path: string }, never, Scope.Scope> =>
|
|
31
38
|
Effect.gen(function* () {
|
|
32
|
-
const path =
|
|
39
|
+
const path = join(tmpdir(), `hf-talos-${label}-${randomUUID()}.yaml`);
|
|
33
40
|
yield* Effect.addFinalizer(() =>
|
|
34
|
-
Effect.tryPromise({ try: () =>
|
|
41
|
+
Effect.tryPromise({ try: () => unlink(path), catch: () => undefined }).pipe(
|
|
35
42
|
Effect.orElseSucceed(() => undefined),
|
|
36
43
|
),
|
|
37
44
|
);
|
|
@@ -63,9 +70,24 @@ export const writeKvValue = (
|
|
|
63
70
|
): Effect.Effect<void, Error, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
64
71
|
Effect.gen(function* () {
|
|
65
72
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
// ⛔ Same vetting as `readKvValue`: `bao` writes credentials, so it is resolved and vetted before
|
|
74
|
+
// any env is built or passed — a fake `bao` in a writable PATH folder is refused, never launched.
|
|
75
|
+
const bao = yield* resolveBao();
|
|
76
|
+
const env = baoEnv();
|
|
77
|
+
if (env === undefined) {
|
|
78
|
+
return yield* Effect.fail(
|
|
79
|
+
new Error(
|
|
80
|
+
`bao kv put ${mount}/${key} refused: BAO_ADDR and BAO_TOKEN must both be set. ` +
|
|
81
|
+
"Without them bao would fall back to a cached login in the operator's home.",
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const result = yield* ChildProcess.make(bao, ['kv', 'put', `${mount}/${key}`, `${field}=-`], {
|
|
86
|
+
env,
|
|
87
|
+
extendEnv: false,
|
|
88
|
+
// ⛔ Mirror readKvValue's spawn: `bao` leads its own process group (killed whole on interrupt)
|
|
89
|
+
// and a SIGTERM-ignoring `bao` is SIGKILLed 1 s later instead of blocking the plan.
|
|
90
|
+
forceKillAfter: Duration.seconds(1),
|
|
69
91
|
stderr: 'pipe',
|
|
70
92
|
stdin: Stream.fromIterable([new TextEncoder().encode(value)]),
|
|
71
93
|
stdout: 'pipe',
|
package/src/talos/credentials.ts
CHANGED
|
@@ -30,12 +30,17 @@
|
|
|
30
30
|
* assigns, and `bao kv get` inserts the KV-v2 `data/` API segment itself — a key already
|
|
31
31
|
* prefixed `data/` reads `<mount>/data/data/<key>`, which the OLD default did.
|
|
32
32
|
*/
|
|
33
|
-
import
|
|
33
|
+
import * as Duration from 'effect/Duration';
|
|
34
34
|
import * as Effect from 'effect/Effect';
|
|
35
35
|
import type * as Scope from 'effect/Scope';
|
|
36
36
|
import * as Stream from 'effect/Stream';
|
|
37
37
|
import * as ChildProcess from 'effect/process/ChildProcess';
|
|
38
38
|
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
39
|
+
import { baoEnv } from './bao-env.ts';
|
|
40
|
+
import { resolveBao } from './talosctl-binary.ts';
|
|
41
|
+
import { mintKvTempFile } from './mint-temp-file.ts';
|
|
42
|
+
|
|
43
|
+
export { mintKvTempFile } from './mint-temp-file.ts';
|
|
39
44
|
|
|
40
45
|
/** Where credentials come from. HomeFlare-specific mount names live in the stack, not here. */
|
|
41
46
|
export type TalosTarget = {
|
|
@@ -81,10 +86,34 @@ export const readKvValue = (
|
|
|
81
86
|
): Effect.Effect<string, Error, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
82
87
|
Effect.gen(function* () {
|
|
83
88
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
89
|
+
// ⛔ `bao` fetches the cluster credentials, so it is resolved and VETTED before any env is built
|
|
90
|
+
// or passed: a fake `bao` in a group/world-writable PATH folder is refused here, never launched.
|
|
91
|
+
const bao = yield* resolveBao();
|
|
92
|
+
const env = baoEnv();
|
|
93
|
+
if (env === undefined) {
|
|
94
|
+
return yield* Effect.fail(
|
|
95
|
+
new Error(
|
|
96
|
+
`bao kv get ${mount}/${key} refused: BAO_ADDR and BAO_TOKEN must both be set. ` +
|
|
97
|
+
"Without them bao would fall back to a cached login in the operator's home.",
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
84
101
|
const result = yield* ChildProcess.make(
|
|
85
|
-
|
|
102
|
+
bao,
|
|
86
103
|
['kv', 'get', '-format=json', `${mount}/${key}`],
|
|
87
|
-
|
|
104
|
+
// ⛔ NO `detached: false`, AND `forceKillAfter` IS SET (round-4 review). Effect detaches on
|
|
105
|
+
// Unix so `bao` leads its own process group, and the scope finalizer signals the WHOLE group
|
|
106
|
+
// (`NodeChildProcessSpawner`: `kill(-pid)`). With `detached: false` a `bao` that forked a
|
|
107
|
+
// sleeper left the grandchild alive, and without `forceKillAfter` a `bao` that ignores
|
|
108
|
+
// SIGTERM kept connect blocked past its deadline. 1 s after SIGTERM the group gets SIGKILL.
|
|
109
|
+
{
|
|
110
|
+
env,
|
|
111
|
+
extendEnv: false,
|
|
112
|
+
forceKillAfter: Duration.seconds(1),
|
|
113
|
+
stderr: 'pipe',
|
|
114
|
+
stdin: 'ignore',
|
|
115
|
+
stdout: 'pipe',
|
|
116
|
+
},
|
|
88
117
|
).pipe(
|
|
89
118
|
spawner.spawn,
|
|
90
119
|
Effect.flatMap((child) =>
|
|
@@ -114,7 +143,15 @@ export const readKvValue = (
|
|
|
114
143
|
);
|
|
115
144
|
}
|
|
116
145
|
|
|
117
|
-
|
|
146
|
+
// ⛔ A V8 SyntaxError quotes the offending input; this stdout may hold the secret itself.
|
|
147
|
+
let parsed: { data?: { data?: Record<string, unknown> } };
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(result.stdout) as typeof parsed;
|
|
150
|
+
} catch {
|
|
151
|
+
return yield* Effect.fail(
|
|
152
|
+
new Error(`bao kv get ${mount}/${key} returned output that is not JSON (not echoed).`),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
118
155
|
const data = parsed.data?.data;
|
|
119
156
|
for (const field of fields) {
|
|
120
157
|
const raw = data?.[field];
|
|
@@ -129,44 +166,13 @@ export const readKvValue = (
|
|
|
129
166
|
});
|
|
130
167
|
|
|
131
168
|
/**
|
|
132
|
-
*
|
|
133
|
-
* `
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* admin client certificate or a machine config's bootstrap token. `Bun.write` takes no mode, so
|
|
137
|
-
* it lands at the process umask — world-readable on this estate — for as long as the resource
|
|
138
|
-
* runs. The name is unguessable, and "unguessable" is not a permission.
|
|
139
|
-
* ⚠️ `wx` IS THE OTHER HALF. `O_EXCL` means this cannot be made to write through a path an
|
|
140
|
-
* attacker pre-created (the classic /tmp symlink race), and with a UUIDv7 name a collision is a
|
|
141
|
-
* genuine error rather than something to paper over.
|
|
142
|
-
* ⚠️ A HARD KILL (SIGKILL, power loss) skips the finalizer and leaves the file behind. 0600 is
|
|
143
|
-
* what makes that survivable rather than a disclosure.
|
|
169
|
+
* ★ MEASURED AGAINST OPENBAO v2.6.2 (C1 fix, LAND red team, 2026-09-26 — isolated in-memory
|
|
170
|
+
* dev server): `bao kv get` on an unwritten KV-v2 key exits 2 with stderr exactly
|
|
171
|
+
* `No value found at <mount>/data/<key>`. `readKvValue`'s Error embeds that text. It is the
|
|
172
|
+
* only signal treated as "not written yet"; a permission denial or a transport failure is not.
|
|
144
173
|
*/
|
|
145
|
-
export const
|
|
146
|
-
|
|
147
|
-
label: string,
|
|
148
|
-
): Effect.Effect<{ readonly path: string }, Error, Scope.Scope> =>
|
|
149
|
-
Effect.gen(function* () {
|
|
150
|
-
const path = `${Bun.env['TMPDIR'] ?? '/tmp'}/hf-talos-${label}-${Bun.randomUUIDv7()}.yaml`;
|
|
151
|
-
yield* Effect.acquireRelease(
|
|
152
|
-
Effect.try({
|
|
153
|
-
try: () => {
|
|
154
|
-
const fd = openSync(path, 'wx', 0o600);
|
|
155
|
-
try {
|
|
156
|
-
writeFileSync(fd, raw);
|
|
157
|
-
} finally {
|
|
158
|
-
closeSync(fd);
|
|
159
|
-
}
|
|
160
|
-
},
|
|
161
|
-
catch: (cause) => new Error(`writing ${label} temp file: ${String(cause)}`),
|
|
162
|
-
}),
|
|
163
|
-
() =>
|
|
164
|
-
Effect.tryPromise({ try: () => Bun.file(path).delete(), catch: () => undefined }).pipe(
|
|
165
|
-
Effect.orElseSucceed(() => undefined),
|
|
166
|
-
),
|
|
167
|
-
);
|
|
168
|
-
return { path };
|
|
169
|
-
});
|
|
174
|
+
export const isVaultKeyAbsent = (error: unknown): boolean =>
|
|
175
|
+
error instanceof Error && /no value found at/i.test(error.message);
|
|
170
176
|
|
|
171
177
|
/**
|
|
172
178
|
* Mint one talosconfig file for `target`.
|
|
@@ -188,10 +194,10 @@ export const mintTalosconfig = (
|
|
|
188
194
|
|
|
189
195
|
/**
|
|
190
196
|
* Mint one temp kubeconfig file from the vault copy `Talos.Kubeconfig` writes at bring-up
|
|
191
|
-
* (K-talos-first-boot, 2026-09-26) — the
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
197
|
+
* (K-talos-first-boot, 2026-09-26) — the file-shaped counterpart to {@link mintTalosconfig}.
|
|
198
|
+
* `Kubernetes.ClusterAdapter` `talos-openbao` does not use it: connect reads the bytes with
|
|
199
|
+
* {@link readKvValue} and keeps them in memory. This remains for a caller that must hand a
|
|
200
|
+
* path to a process.
|
|
195
201
|
*/
|
|
196
202
|
export const mintKubeconfig = (
|
|
197
203
|
target: TalosTarget,
|