1/**
2 * Internal kubeconfig resolution for the built-in cluster adapters: parse a
3 * kubeconfig file, resolve a context to (cluster, user), and turn its auth
4 * stanza — static token, client certificate, or `exec` credential plugin —
5 * into a {@link ClusterTransport}. This is the "anything kubectl can reach"
6 * path: AKS/GKE/EKS CLI-managed contexts work through their exec plugins
7 * (`kubelogin`, `gke-gcloud-auth-plugin`, `aws eks get-token`).
8 */
9import * as Config from "effect/Config";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Path from "effect/Path";
14import * as Stream from "effect/Stream";
15import * as ChildProcess from "effect/process/ChildProcess";
16import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
17import * as YAML from "yaml";
18
19/** A kubeconfig file could not be read, parsed, or resolved. */
20export class KubeConfigError extends Data.TaggedError(
21 "Kubernetes.KubeConfigError",
22)<{
23 readonly message: string;
24 readonly cause?: unknown;
25}> {}
26
27/** An exec credential plugin invocation failed or answered malformed. */
28export class ExecCredentialError extends Data.TaggedError(
29 "Kubernetes.ExecCredentialError",
30)<{
31 readonly message: string;
32 readonly cause?: unknown;
33}> {}
34
35interface KubeConfigFile {
36 "current-context"?: string;
37 clusters?: {
38 name?: string;
39 cluster?: {
40 server?: string;
41 "certificate-authority"?: string;
42 "certificate-authority-data"?: string;
43 "insecure-skip-tls-verify"?: boolean;
44 };
45 }[];
46 users?: {
47 name?: string;
48 user?: KubeConfigUser;
49 }[];
50 contexts?: {
51 name?: string;
52 context?: { cluster?: string; user?: string };
53 }[];
54}
55
56interface KubeConfigUser {
57 token?: string;
58 tokenFile?: string;
59 "client-certificate"?: string;
60 "client-certificate-data"?: string;
61 "client-key"?: string;
62 "client-key-data"?: string;
63 exec?: {
64 command?: string;
65 args?: string[];
66 env?: { name?: string; value?: string }[];
67 apiVersion?: string;
68 };
69}
70
71/** The resolved connection material for one kubeconfig context. */
72export interface ResolvedKubeContext {
73 endpoint: string;
74 certificateAuthorityData?: string;
75 insecureSkipTlsVerify?: boolean;
76 user: KubeConfigUser;
77}
78
79const decodeBase64 = (value: string) =>
80 Buffer.from(value, "base64").toString("utf8");
81
82const expandHome = Effect.fn(function* (input: string) {
83 if (!input.startsWith("~")) return input;
84 const home = yield* Effect.sync(
85 () => process.env.HOME ?? process.env.USERPROFILE,
86 );
87 if (!home) {
88 return yield* Effect.fail(
89 new KubeConfigError({
90 message: `Cannot expand '${input}': no HOME environment variable`,
91 }),
92 );
93 }
94 const path = yield* Path.Path;
95 return path.join(home, input.slice(1));
96});
97
98/** Resolve the kubeconfig file path: explicit → `$KUBECONFIG` → default. */
99export const resolveKubeConfigPath = Effect.fn(function* (
100 explicit: string | undefined,
101) {
102 if (explicit) return yield* expandHome(explicit);
103 const fromEnv = yield* Config.String("KUBECONFIG").pipe(
104 Effect.orElseSucceed(() => undefined),
105 );
106 // $KUBECONFIG may be a path list; use the first entry like kubectl's
107 // effective-config merge order.
108 const candidate = fromEnv?.split(":")[0];
109 if (candidate) return yield* expandHome(candidate);
110 return yield* expandHome("~/.kube/config");
111});
112
113/** Load a kubeconfig file and resolve one context to its cluster + user. */
114export const resolveKubeContext = Effect.fn(function* (options: {
115 path?: string;
116 context?: string;
117}) {
118 const fs = yield* FileSystem.FileSystem;
119 const configPath = yield* resolveKubeConfigPath(options.path);
120
121 const raw = yield* fs.readFileString(configPath).pipe(
122 Effect.mapError(
123 (cause) =>
124 new KubeConfigError({
125 message: `Failed to read kubeconfig at '${configPath}'`,
126 cause,
127 }),
128 ),
129 );
130
131 const parsed = yield* Effect.try({
132 try: () => YAML.parse(raw) as KubeConfigFile,
133 catch: (cause) =>
134 new KubeConfigError({
135 message: `Failed to parse kubeconfig at '${configPath}'`,
136 cause,
137 }),
138 });
139
140 const contextName = options.context ?? parsed["current-context"];
141 if (!contextName) {
142 return yield* Effect.fail(
143 new KubeConfigError({
144 message: `Kubeconfig '${configPath}' has no current-context — pass an explicit context`,
145 }),
146 );
147 }
148
149 const context = parsed.contexts?.find(
150 (entry) => entry.name === contextName,
151 )?.context;
152 if (!context?.cluster) {
153 return yield* Effect.fail(
154 new KubeConfigError({
155 message: `Context '${contextName}' not found in kubeconfig '${configPath}'`,
156 }),
157 );
158 }
159
160 const cluster = parsed.clusters?.find(
161 (entry) => entry.name === context.cluster,
162 )?.cluster;
163 if (!cluster?.server) {
164 return yield* Effect.fail(
165 new KubeConfigError({
166 message: `Cluster '${context.cluster}' (context '${contextName}') has no server in kubeconfig '${configPath}'`,
167 }),
168 );
169 }
170
171 const user =
172 parsed.users?.find((entry) => entry.name === context.user)?.user ?? {};
173
174 let certificateAuthorityData = cluster["certificate-authority-data"];
175 if (!certificateAuthorityData && cluster["certificate-authority"]) {
176 const caPath = yield* expandHome(cluster["certificate-authority"]);
177 const pem = yield* fs.readFileString(caPath).pipe(
178 Effect.mapError(
179 (cause) =>
180 new KubeConfigError({
181 message: `Failed to read certificate authority file '${caPath}'`,
182 cause,
183 }),
184 ),
185 );
186 certificateAuthorityData = Buffer.from(pem, "utf8").toString("base64");
187 }
188
189 return {
190 endpoint: cluster.server,
191 certificateAuthorityData,
192 insecureSkipTlsVerify: cluster["insecure-skip-tls-verify"],
193 user,
194 } satisfies ResolvedKubeContext;
195});
196
197interface ExecCredentialStatus {
198 token?: string;
199 clientCertificateData?: string;
200 clientKeyData?: string;
201 expirationTimestamp?: string;
202}
203
204/** Credentials minted by a user's auth stanza for one request. */
205export interface MintedCredentials {
206 token?: string;
207 clientCert?: { certificate: string; key: string };
208}
209
210// Exec plugin invocations are cached per (command, args) until their
211// expirationTimestamp (or briefly, when the plugin doesn't declare one).
212// Plain cached values, no finalizers — module scope is safe.
213const execCache = new Map<
214 string,
215 { credentials: MintedCredentials; expiresAt: number }
216>();
217
218/**
219 * Run a kubeconfig-style exec credential plugin and parse its
220 * ExecCredential response.
221 */
222export const runExecCredential = Effect.fn(function* (options: {
223 command: string;
224 args?: string[] | undefined;
225 env?: Record<string, string> | undefined;
226}) {
227 const cacheKey = JSON.stringify([
228 options.command,
229 options.args ?? [],
230 options.env ?? {},
231 ]);
232 const cached = execCache.get(cacheKey);
233 const now = yield* Effect.sync(() => Date.now());
234 if (cached && cached.expiresAt > now) {
235 return cached.credentials;
236 }
237
238 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
239 // The child process is scoped to this invocation: spawn, drain stdio,
240 // and close the scope when the plugin exits.
241 const result = yield* ChildProcess.make(options.command, options.args ?? [], {
242 stdin: "ignore",
243 stdout: "pipe",
244 stderr: "pipe",
245 detached: false,
246 extendEnv: true,
247 env: options.env,
248 }).pipe(
249 spawner.spawn,
250 Effect.flatMap((child) =>
251 Effect.all(
252 {
253 exitCode: child.exitCode,
254 stdout: child.stdout.pipe(Stream.decodeText, Stream.mkString),
255 stderr: child.stderr.pipe(Stream.decodeText, Stream.mkString),
256 },
257 { concurrency: "unbounded" },
258 ),
259 ),
260 Effect.scoped,
261 Effect.catchCause((cause) =>
262 Effect.fail(
263 new ExecCredentialError({
264 message: `Failed to run exec credential plugin '${options.command}': ${String(cause)}`,
265 }),
266 ),
267 ),
268 );
269
270 if (result.exitCode !== 0) {
271 return yield* Effect.fail(
272 new ExecCredentialError({
273 message:
274 `Exec credential plugin '${options.command} ${(options.args ?? []).join(" ")}' ` +
275 `exited with code ${String(result.exitCode)}: ${result.stderr.trim()}`,
276 }),
277 );
278 }
279
280 const status = yield* Effect.try({
281 try: () =>
282 (JSON.parse(result.stdout) as { status?: ExecCredentialStatus }).status,
283 catch: (cause) =>
284 new ExecCredentialError({
285 message: `Exec credential plugin '${options.command}' answered malformed JSON`,
286 cause,
287 }),
288 });
289
290 if (
291 !status ||
292 (!status.token && !(status.clientCertificateData && status.clientKeyData))
293 ) {
294 return yield* Effect.fail(
295 new ExecCredentialError({
296 message: `Exec credential plugin '${options.command}' returned no token or client certificate`,
297 }),
298 );
299 }
300
301 const credentials: MintedCredentials = {
302 token: status.token,
303 clientCert:
304 status.clientCertificateData && status.clientKeyData
305 ? {
306 certificate: status.clientCertificateData,
307 key: status.clientKeyData,
308 }
309 : undefined,
310 };
311
312 const expiresAt = status.expirationTimestamp
313 ? // Re-mint 30s before the declared expiry.
314 new Date(status.expirationTimestamp).getTime() - 30_000
315 : // No declared expiry: cache briefly so bursts of requests share one
316 // plugin invocation without holding a stale credential for long.
317 now + 60_000;
318 execCache.set(cacheKey, { credentials, expiresAt });
319
320 return credentials;
321});
322
323/** Mint request credentials from a kubeconfig user's auth stanza. */
324export const mintUserCredentials = Effect.fn(function* (user: KubeConfigUser) {
325 if (user.exec?.command) {
326 return yield* runExecCredential({
327 command: user.exec.command,
328 args: user.exec.args,
329 env: user.exec.env
330 ? Object.fromEntries(
331 user.exec.env
332 .filter(
333 (entry): entry is { name: string; value: string } =>
334 typeof entry.name === "string" &&
335 typeof entry.value === "string",
336 )
337 .map((entry) => [entry.name, entry.value]),
338 )
339 : undefined,
340 });
341 }
342
343 if (user.token) {
344 return { token: user.token } satisfies MintedCredentials;
345 }
346
347 if (user.tokenFile) {
348 const fs = yield* FileSystem.FileSystem;
349 const tokenPath = yield* expandHome(user.tokenFile);
350 const token = yield* fs.readFileString(tokenPath).pipe(
351 Effect.mapError(
352 (cause) =>
353 new KubeConfigError({
354 message: `Failed to read token file '${tokenPath}'`,
355 cause,
356 }),
357 ),
358 );
359 return { token: token.trim() } satisfies MintedCredentials;
360 }
361
362 const certData = user["client-certificate-data"];
363 const keyData = user["client-key-data"];
364 if (certData && keyData) {
365 return {
366 clientCert: {
367 certificate: decodeBase64(certData),
368 key: decodeBase64(keyData),
369 },
370 } satisfies MintedCredentials;
371 }
372
373 const certPath = user["client-certificate"];
374 const keyPath = user["client-key"];
375 if (certPath && keyPath) {
376 const fs = yield* FileSystem.FileSystem;
377 const readPem = (file: string) =>
378 expandHome(file).pipe(
379 Effect.flatMap((resolved) =>
380 fs.readFileString(resolved).pipe(
381 Effect.mapError(
382 (cause) =>
383 new KubeConfigError({
384 message: `Failed to read credential file '${resolved}'`,
385 cause,
386 }),
387 ),
388 ),
389 ),
390 );
391 return {
392 clientCert: {
393 certificate: yield* readPem(certPath),
394 key: yield* readPem(keyPath),
395 },
396 } satisfies MintedCredentials;
397 }
398
399 return yield* Effect.fail(
400 new KubeConfigError({
401 message:
402 "Kubeconfig user has no supported auth stanza (token, tokenFile, " +
403 "client certificate, or exec credential plugin)",
404 }),
405 );
406});