@intentius/chant-k8s-client 0.31.0

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.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Credential policy (chant #1074's managed-cluster half).
3
+ *
4
+ * The exec-plugin caching case runs a real subprocess — a two-line node script
5
+ * that prints an `ExecCredential` and appends a line to a temp file so the test
6
+ * can count invocations. It talks to no cluster and reads no ambient
7
+ * kubeconfig; the API server is still the injected request layer.
8
+ */
9
+
10
+ import { describe, test, expect } from "vitest";
11
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import {
15
+ assertExecCredentialAllowed,
16
+ credentialPathOf,
17
+ DEFAULT_EXEC_ALLOWLIST,
18
+ execCommandName,
19
+ execConfigOf,
20
+ } from "./credentials";
21
+ import { ExecCredentialNotAllowedError } from "./errors";
22
+ import { createK8sClient } from "./client";
23
+ import { apiResourceList, fakeKubeconfig, fakeRequestLayer, statusBody } from "./testing";
24
+
25
+ describe("exec credential allowlist", () => {
26
+ test("a bare command, an absolute path and a Windows executable all reduce to the same name", () => {
27
+ expect(execCommandName("aws")).toBe("aws");
28
+ expect(execCommandName("/usr/local/bin/aws")).toBe("aws");
29
+ expect(execCommandName("C:\\Program Files\\Amazon\\AWSCLIV2\\aws.exe")).toBe("aws");
30
+ });
31
+
32
+ test("the managed-cluster plugins pass; anything else is refused by name", () => {
33
+ for (const command of ["aws", "/opt/homebrew/bin/gke-gcloud-auth-plugin", "kubelogin"]) {
34
+ expect(() => assertExecCredentialAllowed({ name: "u", exec: { command } })).not.toThrow();
35
+ }
36
+ expect(() => assertExecCredentialAllowed({ name: "u", exec: { command: "curl" } })).toThrow(
37
+ ExecCredentialNotAllowedError,
38
+ );
39
+ });
40
+
41
+ test("the refusal names the command and how to allow it", () => {
42
+ const err = (() => {
43
+ try {
44
+ assertExecCredentialAllowed({ name: "u", exec: { command: "harvest" } });
45
+ } catch (e) {
46
+ return e as Error;
47
+ }
48
+ })()!;
49
+ expect(err.message).toContain('"harvest"');
50
+ expect(err.message).toContain("k8s.execCredentialPlugins");
51
+ });
52
+
53
+ test("an exec stanza hidden under authProvider is gated too", () => {
54
+ const user = { name: "u", authProvider: { name: "exec", config: { exec: { command: "harvest" } } } };
55
+ expect(execConfigOf(user)?.command).toBe("harvest");
56
+ expect(() => assertExecCredentialAllowed(user)).toThrow(ExecCredentialNotAllowedError);
57
+ });
58
+
59
+ test("a user with no exec plugin is never gated", () => {
60
+ expect(() => assertExecCredentialAllowed({ name: "u", token: "t" })).not.toThrow();
61
+ expect(() => assertExecCredentialAllowed(undefined)).not.toThrow();
62
+ });
63
+
64
+ test("an explicit allowlist replaces the default", () => {
65
+ expect(() => assertExecCredentialAllowed({ name: "u", exec: { command: "aws" } }, ["kubelogin"])).toThrow();
66
+ expect(() => assertExecCredentialAllowed({ name: "u", exec: { command: "custom" } }, ["custom"])).not.toThrow();
67
+ });
68
+
69
+ test("the default allowlist covers EKS, AKS and GKE", () => {
70
+ expect(DEFAULT_EXEC_ALLOWLIST).toContain("aws");
71
+ expect(DEFAULT_EXEC_ALLOWLIST).toContain("kubelogin");
72
+ expect(DEFAULT_EXEC_ALLOWLIST).toContain("gke-gcloud-auth-plugin");
73
+ });
74
+ });
75
+
76
+ describe("credential provenance", () => {
77
+ test.each([
78
+ [{ name: "u", exec: { command: "aws" } }, "exec-plugin"],
79
+ [{ name: "u", authProvider: { name: "oidc" } }, "auth-provider"],
80
+ [{ name: "u", token: "t" }, "token"],
81
+ [{ name: "u", certData: "c" }, "client-certificate"],
82
+ [{ name: "u", username: "admin", password: "p" }, "basic-auth"],
83
+ [{ name: "u" }, "none"],
84
+ ])("%o is %s", (user, expected) => {
85
+ expect(credentialPathOf(user).credential).toBe(expected);
86
+ });
87
+ });
88
+
89
+ describe("exec credential caching", () => {
90
+ test("one plugin invocation serves an entire multi-entity observation", async () => {
91
+ const dir = mkdtempSync(join(tmpdir(), "chant-execauth-"));
92
+ const counterFile = join(dir, "invocations.log");
93
+ const scriptFile = join(dir, "plugin.mjs");
94
+ writeFileSync(
95
+ scriptFile,
96
+ [
97
+ `import { appendFileSync } from "node:fs";`,
98
+ `appendFileSync(${JSON.stringify(counterFile)}, "x\\n");`,
99
+ `process.stdout.write(JSON.stringify({`,
100
+ ` apiVersion: "client.authentication.k8s.io/v1",`,
101
+ ` kind: "ExecCredential",`,
102
+ ` status: { token: "exec-issued-token", expirationTimestamp: "2099-01-01T00:00:00Z" },`,
103
+ `}));`,
104
+ ].join("\n"),
105
+ );
106
+
107
+ const deployments = apiResourceList("apps/v1", [{ name: "deployments", kind: "Deployment" }]);
108
+ const layer = fakeRequestLayer((req) => {
109
+ if (req.path === "/apis/apps/v1") return { body: deployments };
110
+ if (req.path.startsWith("/apis/apps/v1/namespaces/prod/deployments/")) {
111
+ return { body: { apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "x", uid: "u" } } };
112
+ }
113
+ return { status: 404, body: statusBody(404, "NotFound", "no") };
114
+ });
115
+
116
+ const client = await createK8sClient({
117
+ // The "plugin" is this node binary running the script above — a real
118
+ // subprocess on the real ExecAuth path, with nothing to install.
119
+ kubeconfig: fakeKubeconfig({ exec: { command: process.execPath, args: [scriptFile] } }),
120
+ execAllowlist: [process.execPath],
121
+ requestLayer: layer,
122
+ });
123
+
124
+ await client.concurrently(
125
+ Array.from({ length: 20 }, (_, i) => i),
126
+ (i) => client.read({ apiVersion: "apps/v1", kind: "Deployment", name: `web-${i}`, namespace: "prod" }),
127
+ );
128
+
129
+ const invocations = existsSync(counterFile) ? readFileSync(counterFile, "utf8").trim().split("\n").length : 0;
130
+ rmSync(dir, { recursive: true, force: true });
131
+
132
+ // 21 requests (discovery + 20 reads) authorized by one plugin run.
133
+ expect(layer.requests.length).toBe(21);
134
+ expect(invocations).toBe(1);
135
+ for (const req of layer.requests) {
136
+ expect(req.headers.Authorization).toBe("Bearer exec-issued-token");
137
+ }
138
+ }, 30_000);
139
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Credential policy — chant #1074's managed-cluster half.
3
+ *
4
+ * On EKS, AKS and GKE, kubeconfig authentication is an **exec credential
5
+ * plugin**: `aws eks get-token`, `kubelogin`, `gke-gcloud-auth-plugin`. So on
6
+ * the clusters most people actually run, a "native" client still spawns a
7
+ * process — once per token instead of once per resource read. That is still
8
+ * the win the issue is after, but it is worth stating rather than implying,
9
+ * because an exec plugin runs an arbitrary binary named in a file chant did
10
+ * not write.
11
+ *
12
+ * Three things are chant's to decide, and they live here:
13
+ *
14
+ * 1. **Allowlist.** The plugin's command must be on a list, not merely present
15
+ * in the kubeconfig. The default covers the three managed providers.
16
+ * 2. **Caching.** `@kubernetes/client-node`'s `ExecAuth` caches a credential
17
+ * until its `expirationTimestamp`, keyed by kubeconfig user. The client
18
+ * builds one `KubeConfig` per session and reuses it for every request, so a
19
+ * 200-entity observation invokes `aws eks get-token` once rather than 200
20
+ * times. That is a property of not rebuilding the config, so it is asserted
21
+ * in this package's tests rather than reimplemented here.
22
+ * 3. **Provenance.** Which credential path authorized a read is recorded on
23
+ * the client and travels with the observation.
24
+ */
25
+
26
+ import { ExecCredentialNotAllowedError } from "./errors";
27
+ import type { CredentialPath } from "./types";
28
+
29
+ /**
30
+ * Exec credential plugins chant will execute without being asked twice.
31
+ *
32
+ * The three managed-Kubernetes providers plus `kubectl`, which is what
33
+ * `kubectl config set-credentials --exec-command` writes for OIDC setups that
34
+ * shell back through the binary the user already trusts. Anything else has to
35
+ * be named explicitly in `k8s.execCredentialPlugins`.
36
+ */
37
+ export const DEFAULT_EXEC_ALLOWLIST: readonly string[] = [
38
+ "aws", // EKS — `aws eks get-token`
39
+ "aws-iam-authenticator", // EKS, pre-`aws eks get-token`
40
+ "gke-gcloud-auth-plugin", // GKE
41
+ "kubelogin", // AKS
42
+ "kubectl", // OIDC via `kubectl oidc-login`, and k3d/kind setups
43
+ ];
44
+
45
+ /** The exec stanza of a kubeconfig user, as far as this module cares. */
46
+ export interface ExecConfig {
47
+ command?: string;
48
+ args?: string[];
49
+ env?: Array<{ name: string; value: string }>;
50
+ }
51
+
52
+ /** The kubeconfig user fields this module reads. */
53
+ export interface KubeConfigUser {
54
+ name?: string;
55
+ exec?: ExecConfig;
56
+ authProvider?: { name?: string; config?: { exec?: ExecConfig } };
57
+ token?: string;
58
+ certData?: string;
59
+ certFile?: string;
60
+ username?: string;
61
+ password?: string;
62
+ }
63
+
64
+ /**
65
+ * The exec stanza this user authenticates with, if any. `authProvider` can
66
+ * carry one too — client-node's `ExecAuth.isAuthProvider` accepts both shapes,
67
+ * so the gate has to look in both places or it is trivially bypassed.
68
+ */
69
+ export function execConfigOf(user: KubeConfigUser | null | undefined): ExecConfig | undefined {
70
+ if (!user) return undefined;
71
+ if (user.exec) return user.exec;
72
+ const providerExec = user.authProvider?.config?.exec;
73
+ if (providerExec) return providerExec;
74
+ return undefined;
75
+ }
76
+
77
+ /**
78
+ * Reduce a plugin command to the name that is allowlisted: `aws` for `aws`,
79
+ * for `/usr/local/bin/aws`, and for `C:\\tools\\aws.exe`. Matching the bare
80
+ * name rather than the full path is deliberate — the path varies per machine,
81
+ * and pinning it would make the allowlist unusable — but it does mean the
82
+ * allowlist expresses "which tool", not "which file".
83
+ */
84
+ export function execCommandName(command: string): string {
85
+ const base = command.split(/[\\/]/).pop() ?? command;
86
+ return base.replace(/\.(exe|cmd|bat)$/i, "");
87
+ }
88
+
89
+ /**
90
+ * Throw unless the user's exec plugin (if it has one) is allowlisted. Called
91
+ * before the client issues its first request, so a refusal happens before any
92
+ * binary runs — the check is worthless if it fires after the spawn.
93
+ */
94
+ export function assertExecCredentialAllowed(
95
+ user: KubeConfigUser | null | undefined,
96
+ allowlist: readonly string[] = DEFAULT_EXEC_ALLOWLIST,
97
+ ): void {
98
+ const exec = execConfigOf(user);
99
+ if (!exec?.command) return;
100
+ const name = execCommandName(exec.command);
101
+ if (allowlist.some((entry) => execCommandName(entry) === name)) return;
102
+ throw new ExecCredentialNotAllowedError(exec.command, allowlist);
103
+ }
104
+
105
+ /** Which credential path a kubeconfig user represents, for provenance. */
106
+ export function credentialPathOf(user: KubeConfigUser | null | undefined): {
107
+ credential: CredentialPath;
108
+ execCommand?: string;
109
+ } {
110
+ const exec = execConfigOf(user);
111
+ if (exec?.command) return { credential: "exec-plugin", execCommand: exec.command };
112
+ if (user?.authProvider?.name) return { credential: "auth-provider" };
113
+ if (user?.token) return { credential: "token" };
114
+ if (user?.certData || user?.certFile) return { credential: "client-certificate" };
115
+ if (user?.username) return { credential: "basic-auth" };
116
+ return { credential: "none" };
117
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Typed failures — chant #1074.
3
+ *
4
+ * The kubectl path this client replaces reported failures as a non-zero exit
5
+ * plus a line of English on stderr, which every caller then had to pattern
6
+ * match (`classifyKubectlFailure` in core is that pattern matcher). The API
7
+ * server already sends a machine-readable `Status` object with a numeric code
8
+ * and a `reason` enum; these errors carry it through instead of re-deriving it
9
+ * from prose.
10
+ */
11
+
12
+ /** The `Status` object a Kubernetes API server returns on a failed request. */
13
+ export interface K8sStatus {
14
+ kind?: string;
15
+ apiVersion?: string;
16
+ status?: string;
17
+ message?: string;
18
+ /** e.g. "NotFound", "Forbidden", "Unauthorized", "Conflict", "AlreadyExists". */
19
+ reason?: string;
20
+ code?: number;
21
+ details?: unknown;
22
+ }
23
+
24
+ /**
25
+ * The API server answered, and the answer was a failure. `statusCode` and
26
+ * `reason` come from the response, not from parsing text.
27
+ */
28
+ export class K8sApiError extends Error {
29
+ constructor(
30
+ public readonly statusCode: number,
31
+ public readonly reason: string | undefined,
32
+ public readonly apiMessage: string,
33
+ /** What was being addressed, e.g. `apps/v1 Deployment prod/web`. */
34
+ public readonly target?: string,
35
+ public readonly status?: K8sStatus,
36
+ ) {
37
+ super(
38
+ `${target ? `${target}: ` : ""}${apiMessage || "request failed"} ` +
39
+ `(HTTP ${statusCode}${reason ? `, ${reason}` : ""})`,
40
+ );
41
+ this.name = "K8sApiError";
42
+ }
43
+
44
+ /** The object is not there. The only failure that establishes absence. */
45
+ get notFound(): boolean {
46
+ return this.statusCode === 404 || this.reason === "NotFound";
47
+ }
48
+
49
+ /** RBAC denied the read. Proves nothing about whether the object exists. */
50
+ get forbidden(): boolean {
51
+ return this.statusCode === 403 || this.reason === "Forbidden";
52
+ }
53
+
54
+ /** No usable credentials for this cluster. */
55
+ get unauthorized(): boolean {
56
+ return this.statusCode === 401 || this.reason === "Unauthorized";
57
+ }
58
+
59
+ /** Server-side-apply field-ownership conflict (chant #1075 surfaces these properly). */
60
+ get conflict(): boolean {
61
+ return this.statusCode === 409 || this.reason === "Conflict";
62
+ }
63
+
64
+ /**
65
+ * Build from a raw response body, which is a `Status` on every well-behaved
66
+ * Kubernetes error and occasionally plain text from a proxy in front of one.
67
+ */
68
+ static fromResponse(statusCode: number, body: string, target?: string): K8sApiError {
69
+ let status: K8sStatus | undefined;
70
+ try {
71
+ const parsed: unknown = JSON.parse(body);
72
+ if (parsed && typeof parsed === "object" && (parsed as K8sStatus).kind === "Status") {
73
+ status = parsed as K8sStatus;
74
+ }
75
+ } catch {
76
+ /* not JSON — a proxy or ingress error page */
77
+ }
78
+ const message = status?.message ?? firstLine(body) ?? "";
79
+ return new K8sApiError(statusCode, status?.reason, message, target, status);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The request never reached an API server — DNS, TCP, TLS, proxy, or an
85
+ * aborted signal. Distinct from {@link K8sApiError} because "I could not
86
+ * connect" and "the server said no" are different observations.
87
+ */
88
+ export class K8sTransportError extends Error {
89
+ constructor(
90
+ message: string,
91
+ public readonly target?: string,
92
+ options?: { cause?: unknown },
93
+ ) {
94
+ super(target ? `${target}: ${message}` : message);
95
+ this.name = "K8sTransportError";
96
+ if (options && "cause" in options) (this as { cause?: unknown }).cause = options.cause;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * `@kubernetes/client-node` is not installed. It is an ordinary dependency of
102
+ * this package, so this only happens when the package tree was pruned
103
+ * (`npm install --omit=optional`, a slimmed container image). Named separately
104
+ * so the k8s lexicon can tell a missing dependency from a broken cluster.
105
+ */
106
+ export class K8sClientUnavailableError extends Error {
107
+ constructor(cause?: unknown) {
108
+ super(
109
+ "the Kubernetes API client is unavailable — @kubernetes/client-node could not be loaded. " +
110
+ "Install it with `npm i @intentius/chant-k8s-client` (it is an optional dependency of " +
111
+ "@intentius/chant-lexicon-k8s, so `--omit=optional` installs skip it).",
112
+ );
113
+ this.name = "K8sClientUnavailableError";
114
+ if (cause !== undefined) (this as { cause?: unknown }).cause = cause;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * The kubeconfig names an exec credential plugin that is not on the allowlist.
120
+ *
121
+ * An exec plugin is an arbitrary binary named in a file chant did not write,
122
+ * run with the CLI's privileges. EKS/AKS/GKE all need one, so refusing them
123
+ * outright would make this client useless on managed clusters — but executing
124
+ * whatever the file names is not a default worth having either.
125
+ */
126
+ export class ExecCredentialNotAllowedError extends Error {
127
+ constructor(
128
+ public readonly command: string,
129
+ public readonly allowed: readonly string[],
130
+ ) {
131
+ super(
132
+ `k8s: the kubeconfig for this context authenticates with the exec credential plugin ` +
133
+ `"${command}", which is not on chant's allowlist (${allowed.join(", ")}). ` +
134
+ `An exec plugin is an arbitrary binary named in a file chant did not write. ` +
135
+ `If "${command}" is expected, add it to k8s.execCredentialPlugins in chant.config.ts.`,
136
+ );
137
+ this.name = "ExecCredentialNotAllowedError";
138
+ }
139
+ }
140
+
141
+ /** The kubeconfig could not be read, or names no usable cluster/context. */
142
+ export class KubeConfigError extends Error {
143
+ constructor(message: string) {
144
+ super(`k8s: ${message}`);
145
+ this.name = "KubeConfigError";
146
+ }
147
+ }
148
+
149
+ /**
150
+ * The cluster's own discovery does not serve this kind. Distinct from a 404 on
151
+ * an instance: no instance of an unserved kind can exist, which is a real
152
+ * absence rather than an unread hole.
153
+ */
154
+ export class UnknownResourceError extends Error {
155
+ constructor(
156
+ public readonly selectorText: string,
157
+ message?: string,
158
+ ) {
159
+ super(message ?? `k8s: the cluster's API discovery reports no resource matching "${selectorText}"`);
160
+ this.name = "UnknownResourceError";
161
+ }
162
+ }
163
+
164
+ function firstLine(text: string, max = 300): string | undefined {
165
+ const line = text.split("\n").find((l) => l.trim().length > 0)?.trim();
166
+ if (!line) return undefined;
167
+ return line.length > max ? `${line.slice(0, max - 3)}...` : line;
168
+ }
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `@intentius/chant-k8s-client` — the typed Kubernetes API client behind the
3
+ * k8s lexicon's read and write paths (chant #1074, epic #1073).
4
+ *
5
+ * It is a package rather than a directory inside the lexicon for one reason:
6
+ * this is the first chant code that holds live cluster credentials, and the
7
+ * synthesis-purity boundary around it should be structural rather than a lint
8
+ * rule. `chant build` cannot resolve this package, because nothing on the
9
+ * build path imports it — the lexicon reaches it through a dynamic import from
10
+ * modules that are themselves only loaded by the observation and Op paths, and
11
+ * `examples/k8s-client-boundary.test.ts` walks the static import graph to prove
12
+ * it stays that way.
13
+ */
14
+
15
+ export {
16
+ createK8sClient,
17
+ readAmbientContext,
18
+ loadClientNode,
19
+ isK8sClientAvailable,
20
+ apiVersionPath,
21
+ splitApiVersion,
22
+ selectorText,
23
+ refText,
24
+ } from "./client";
25
+ export type { K8sClient, ReadOptions, ApplyOptions } from "./client";
26
+
27
+ export {
28
+ K8sApiError,
29
+ K8sTransportError,
30
+ K8sClientUnavailableError,
31
+ ExecCredentialNotAllowedError,
32
+ KubeConfigError,
33
+ UnknownResourceError,
34
+ } from "./errors";
35
+ export type { K8sStatus } from "./errors";
36
+
37
+ export {
38
+ DEFAULT_EXEC_ALLOWLIST,
39
+ assertExecCredentialAllowed,
40
+ credentialPathOf,
41
+ execConfigOf,
42
+ execCommandName,
43
+ } from "./credentials";
44
+ export type { ExecConfig, KubeConfigUser } from "./credentials";
45
+
46
+ export { mapConcurrent, DEFAULT_CONCURRENCY } from "./concurrency";
47
+
48
+ export type {
49
+ ApiResourceInfo,
50
+ ClientProvenance,
51
+ CredentialPath,
52
+ K8sClientOptions,
53
+ K8sObject,
54
+ ObjectRef,
55
+ RequestContextLike,
56
+ RequestLayer,
57
+ ResourceSelector,
58
+ ResponseContextLike,
59
+ } from "./types";
package/src/testing.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Test doubles for the one seam this package exposes.
3
+ *
4
+ * A fake here replaces `@kubernetes/client-node`'s HTTP send and nothing
5
+ * above it: the handler receives the real, fully built request — the URL the
6
+ * client constructed from discovery, the method, and the headers the
7
+ * kubeconfig's auth path wrote, `Authorization` included. So a test that uses
8
+ * it still exercises kubeconfig parsing, context selection, credential policy,
9
+ * discovery and path construction for real.
10
+ *
11
+ * It exists in the shipped package rather than in a test helper because both
12
+ * this package's tests and the k8s lexicon's tests need it, and because a
13
+ * consumer wiring chant into their own harness needs the same thing.
14
+ */
15
+
16
+ import type { RequestContextLike, RequestLayer, ResponseContextLike } from "./types";
17
+
18
+ /** What a {@link FakeRequestHandler} sees. */
19
+ export interface RecordedRequest {
20
+ method: string;
21
+ /** Full URL including query string. */
22
+ url: string;
23
+ /** Path only, query stripped. */
24
+ path: string;
25
+ query: Record<string, string>;
26
+ headers: Record<string, string>;
27
+ body: unknown;
28
+ }
29
+
30
+ /** What a handler returns. `body` is stringified when it is not already a string. */
31
+ export interface FakeResponse {
32
+ status?: number;
33
+ body?: unknown;
34
+ headers?: Record<string, string>;
35
+ }
36
+
37
+ export type FakeRequestHandler = (request: RecordedRequest) => FakeResponse | Promise<FakeResponse>;
38
+
39
+ /** A recording {@link RequestLayer} driven by `handler`. */
40
+ export interface FakeRequestLayer extends RequestLayer {
41
+ /** Every request the client issued, in order. */
42
+ readonly requests: RecordedRequest[];
43
+ /** Paths only, in order — the usual assertion target. */
44
+ paths(): string[];
45
+ }
46
+
47
+ /**
48
+ * Build a fake request layer. Anything the handler does not answer 200s with
49
+ * an empty object, so a test only has to describe the responses it cares about.
50
+ */
51
+ export function fakeRequestLayer(handler: FakeRequestHandler): FakeRequestLayer {
52
+ const requests: RecordedRequest[] = [];
53
+
54
+ return {
55
+ requests,
56
+ paths: () => requests.map((r) => r.path),
57
+ async send(request: RequestContextLike): Promise<ResponseContextLike> {
58
+ const url = request.getUrl();
59
+ const parsed = new URL(url, "http://placeholder.invalid");
60
+ const recorded: RecordedRequest = {
61
+ method: String(request.getHttpMethod()),
62
+ url,
63
+ path: parsed.pathname,
64
+ query: Object.fromEntries(parsed.searchParams.entries()),
65
+ headers: request.getHeaders(),
66
+ body: request.getBody(),
67
+ };
68
+ requests.push(recorded);
69
+
70
+ const result = await handler(recorded);
71
+ const status = result.status ?? 200;
72
+ const body =
73
+ result.body === undefined ? "" : typeof result.body === "string" ? result.body : JSON.stringify(result.body);
74
+ return {
75
+ httpStatusCode: status,
76
+ headers: { "content-type": "application/json", ...(result.headers ?? {}) },
77
+ body: { text: async () => body },
78
+ };
79
+ },
80
+ };
81
+ }
82
+
83
+ /** A Kubernetes `Status` failure body, for driving typed-error assertions. */
84
+ export function statusBody(code: number, reason: string, message: string): Record<string, unknown> {
85
+ return {
86
+ kind: "Status",
87
+ apiVersion: "v1",
88
+ metadata: {},
89
+ status: "Failure",
90
+ message,
91
+ reason,
92
+ code,
93
+ };
94
+ }
95
+
96
+ /** Options for {@link fakeKubeconfig}. */
97
+ export interface FakeKubeconfigOptions {
98
+ contexts?: Array<{ name: string; cluster?: string; user?: string; namespace?: string }>;
99
+ currentContext?: string;
100
+ server?: string;
101
+ /** Static bearer token. Mutually exclusive with `exec` in practice. */
102
+ token?: string;
103
+ /** An exec credential plugin stanza, for allowlist and caching tests. */
104
+ exec?: { command: string; args?: string[]; env?: Array<{ name: string; value: string }> };
105
+ }
106
+
107
+ /**
108
+ * A literal kubeconfig, so no test ever reads the developer's real one. Every
109
+ * test in this repo that builds a client passes one of these.
110
+ */
111
+ export function fakeKubeconfig(options: FakeKubeconfigOptions = {}): string {
112
+ const server = options.server ?? "https://cluster.test:6443";
113
+ const contexts = options.contexts ?? [{ name: "test-context" }];
114
+ const current = options.currentContext ?? contexts[0].name;
115
+ const userStanza = options.exec
116
+ ? [
117
+ " exec:",
118
+ " apiVersion: client.authentication.k8s.io/v1",
119
+ ` command: ${JSON.stringify(options.exec.command)}`,
120
+ ...(options.exec.args?.length
121
+ ? [" args:", ...options.exec.args.map((a) => ` - ${JSON.stringify(a)}`)]
122
+ : []),
123
+ ...(options.exec.env?.length
124
+ ? [
125
+ " env:",
126
+ ...options.exec.env.flatMap((e) => [
127
+ ` - name: ${e.name}`,
128
+ ` value: ${JSON.stringify(e.value)}`,
129
+ ]),
130
+ ]
131
+ : []),
132
+ ]
133
+ : [` token: ${JSON.stringify(options.token ?? "test-token")}`];
134
+
135
+ const users = [...new Set(contexts.map((c) => c.user ?? "test-user"))];
136
+ const clusters = [...new Set(contexts.map((c) => c.cluster ?? "test-cluster"))];
137
+
138
+ return [
139
+ "apiVersion: v1",
140
+ "kind: Config",
141
+ `current-context: ${current}`,
142
+ "clusters:",
143
+ ...clusters.flatMap((name) => [
144
+ ` - name: ${name}`,
145
+ " cluster:",
146
+ ` server: ${server}`,
147
+ " insecure-skip-tls-verify: true",
148
+ ]),
149
+ "users:",
150
+ ...users.flatMap((name) => [` - name: ${name}`, " user:", ...userStanza]),
151
+ "contexts:",
152
+ ...contexts.flatMap((c) => [
153
+ ` - name: ${c.name}`,
154
+ " context:",
155
+ ` cluster: ${c.cluster ?? "test-cluster"}`,
156
+ ` user: ${c.user ?? "test-user"}`,
157
+ ...(c.namespace ? [` namespace: ${c.namespace}`] : []),
158
+ ]),
159
+ "",
160
+ ].join("\n");
161
+ }
162
+
163
+ /** An `APIResourceList` body, the discovery response the client resolves against. */
164
+ export function apiResourceList(
165
+ groupVersion: string,
166
+ resources: Array<{
167
+ name: string;
168
+ kind: string;
169
+ namespaced?: boolean;
170
+ singularName?: string;
171
+ shortNames?: string[];
172
+ verbs?: string[];
173
+ }>,
174
+ ): Record<string, unknown> {
175
+ return {
176
+ kind: "APIResourceList",
177
+ apiVersion: "v1",
178
+ groupVersion,
179
+ resources: resources.map((r) => ({
180
+ name: r.name,
181
+ singularName: r.singularName ?? "",
182
+ namespaced: r.namespaced ?? true,
183
+ kind: r.kind,
184
+ verbs: r.verbs ?? ["get", "list", "watch", "create", "update", "patch", "delete"],
185
+ ...(r.shortNames ? { shortNames: r.shortNames } : {}),
186
+ })),
187
+ };
188
+ }