@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.
- package/dist/client.d.ts +122 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/concurrency.d.ts +20 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/credentials.d.ts +85 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/testing.d.ts +84 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/types.d.ts +146 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +51 -0
- package/src/client.test.ts +407 -0
- package/src/client.ts +570 -0
- package/src/concurrency.ts +40 -0
- package/src/credentials.test.ts +139 -0
- package/src/credentials.ts +117 -0
- package/src/errors.ts +168 -0
- package/src/index.ts +59 -0
- package/src/testing.ts +188 -0
- package/src/types.ts +156 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chant Kubernetes API client — chant #1074.
|
|
3
|
+
*
|
|
4
|
+
* ## What is rented and what is chant's
|
|
5
|
+
*
|
|
6
|
+
* `@kubernetes/client-node` supplies transport and authentication: kubeconfig
|
|
7
|
+
* parsing and merging, client certificates, bearer tokens, exec credential
|
|
8
|
+
* plugins with expiry-aware caching, in-cluster service-account credentials,
|
|
9
|
+
* TLS (CA bundles, `insecure-skip-tls-verify`, `tls-server-name`), HTTP and
|
|
10
|
+
* SOCKS proxies, and impersonation. That work is done, maintained, and
|
|
11
|
+
* security-sensitive; reimplementing it buys nothing.
|
|
12
|
+
*
|
|
13
|
+
* Chant supplies the rest: resource resolution through the cluster's own
|
|
14
|
+
* discovery (rather than a table someone has to keep extending), bounded
|
|
15
|
+
* concurrency, typed failures, an exec-plugin allowlist, and credential
|
|
16
|
+
* provenance.
|
|
17
|
+
*
|
|
18
|
+
* ## Raw objects, not deserialized models
|
|
19
|
+
*
|
|
20
|
+
* The library also ships `KubernetesObjectApi`, which does path construction
|
|
21
|
+
* and discovery. It is not used, for one reason: it runs every response
|
|
22
|
+
* through `ObjectSerializer`, which coerces known kinds into generated model
|
|
23
|
+
* classes — dropping fields the model does not declare and turning timestamps
|
|
24
|
+
* into `Date`s — while passing CRDs through raw. An observation path must not
|
|
25
|
+
* behave differently for a Deployment and a RayCluster, and `managedFields`
|
|
26
|
+
* (the epic's whole point, chant #1076) is exactly the sort of field a model
|
|
27
|
+
* coercion loses. So requests are issued against the library's `RequestContext`
|
|
28
|
+
* and the JSON is used as it arrived.
|
|
29
|
+
*
|
|
30
|
+
* ## The one seam
|
|
31
|
+
*
|
|
32
|
+
* `requestLayer` replaces the library's HTTP send and nothing else. Everything
|
|
33
|
+
* above it — kubeconfig parsing, context selection, URL construction, the auth
|
|
34
|
+
* path that writes the `Authorization` header — runs for real, which is what
|
|
35
|
+
* makes a test that injects one worth writing.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type * as k8s from "@kubernetes/client-node";
|
|
39
|
+
import {
|
|
40
|
+
K8sApiError,
|
|
41
|
+
K8sClientUnavailableError,
|
|
42
|
+
K8sTransportError,
|
|
43
|
+
KubeConfigError,
|
|
44
|
+
UnknownResourceError,
|
|
45
|
+
} from "./errors";
|
|
46
|
+
import { assertExecCredentialAllowed, credentialPathOf, DEFAULT_EXEC_ALLOWLIST } from "./credentials";
|
|
47
|
+
import { DEFAULT_CONCURRENCY, mapConcurrent } from "./concurrency";
|
|
48
|
+
import type {
|
|
49
|
+
ApiResourceInfo,
|
|
50
|
+
ClientProvenance,
|
|
51
|
+
K8sClientOptions,
|
|
52
|
+
K8sObject,
|
|
53
|
+
ObjectRef,
|
|
54
|
+
RequestContextLike,
|
|
55
|
+
ResourceSelector,
|
|
56
|
+
ResponseContextLike,
|
|
57
|
+
} from "./types";
|
|
58
|
+
|
|
59
|
+
type ClientNode = typeof import("@kubernetes/client-node");
|
|
60
|
+
|
|
61
|
+
let clientNodeModule: Promise<ClientNode> | undefined;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Load `@kubernetes/client-node`, once per process.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately a function, not a module-level `await import`: this package is
|
|
67
|
+
* an optional dependency reached only from chant's read/write paths, and
|
|
68
|
+
* resolving it at module load would defeat that. Nothing here touches the
|
|
69
|
+
* filesystem at module init either (chant #1081).
|
|
70
|
+
*/
|
|
71
|
+
export async function loadClientNode(): Promise<ClientNode> {
|
|
72
|
+
if (!clientNodeModule) {
|
|
73
|
+
clientNodeModule = import("@kubernetes/client-node").catch((err: unknown) => {
|
|
74
|
+
clientNodeModule = undefined;
|
|
75
|
+
throw new K8sClientUnavailableError(err);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return clientNodeModule;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when `@kubernetes/client-node` can be loaded in this install. */
|
|
82
|
+
export async function isK8sClientAvailable(): Promise<boolean> {
|
|
83
|
+
try {
|
|
84
|
+
await loadClientNode();
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The kubeconfig's own current-context, without building a client.
|
|
93
|
+
*
|
|
94
|
+
* This is what the environment→cluster binding compares against (chant #1100):
|
|
95
|
+
* "the ambient context" is a property of the kubeconfig, and reading it should
|
|
96
|
+
* not require the kubeconfig to also resolve to a usable cluster — a binding
|
|
97
|
+
* pointing at a valid context has to survive a broken current-context, which is
|
|
98
|
+
* exactly the situation the binding exists to fix. Returns undefined when no
|
|
99
|
+
* kubeconfig can be read at all.
|
|
100
|
+
*/
|
|
101
|
+
export async function readAmbientContext(
|
|
102
|
+
options: Pick<K8sClientOptions, "kubeconfig" | "kubeconfigPath"> = {},
|
|
103
|
+
): Promise<string | undefined> {
|
|
104
|
+
const mod = await loadClientNode();
|
|
105
|
+
const kc = new mod.KubeConfig();
|
|
106
|
+
try {
|
|
107
|
+
if (options.kubeconfig !== undefined) kc.loadFromString(options.kubeconfig);
|
|
108
|
+
else if (options.kubeconfigPath !== undefined) kc.loadFromFile(options.kubeconfigPath);
|
|
109
|
+
else kc.loadFromDefault();
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
return kc.getCurrentContext() || undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Options for a single object read. */
|
|
117
|
+
export interface ReadOptions {
|
|
118
|
+
signal?: AbortSignal;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Options for {@link K8sClient.apply}. */
|
|
122
|
+
export interface ApplyOptions {
|
|
123
|
+
/** Field manager recorded on the objects this apply owns. Default `chant`. */
|
|
124
|
+
fieldManager?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Take ownership of fields another manager owns instead of failing with a
|
|
127
|
+
* 409. Default false — chant #1075 is where the conflict surface proper
|
|
128
|
+
* lives; here a conflict simply arrives as a typed {@link K8sApiError}.
|
|
129
|
+
*/
|
|
130
|
+
force?: boolean;
|
|
131
|
+
/** Server-side dry run — validates and returns the result, persists nothing. */
|
|
132
|
+
dryRun?: boolean;
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The client surface the k8s lexicon consumes. */
|
|
137
|
+
export interface K8sClient {
|
|
138
|
+
/** Where this client is pointed and what authorized it. */
|
|
139
|
+
readonly provenance: ClientProvenance;
|
|
140
|
+
/** Namespace the resolved context defaults to. */
|
|
141
|
+
readonly defaultNamespace: string;
|
|
142
|
+
/**
|
|
143
|
+
* Resolve a selector against the cluster's API discovery. Returns undefined
|
|
144
|
+
* when discovery answered and reported no such resource — which means no
|
|
145
|
+
* instance of it can exist.
|
|
146
|
+
*/
|
|
147
|
+
resolve(selector: ResourceSelector, signal?: AbortSignal): Promise<ApiResourceInfo | undefined>;
|
|
148
|
+
/** GET one object. Throws {@link K8sApiError} with `notFound` when absent. */
|
|
149
|
+
read(ref: ObjectRef, options?: ReadOptions): Promise<K8sObject>;
|
|
150
|
+
/** GET one object, returning undefined instead of throwing on a 404. */
|
|
151
|
+
readIfPresent(ref: ObjectRef, options?: ReadOptions): Promise<K8sObject | undefined>;
|
|
152
|
+
/** LIST a kind, optionally namespaced. Follows `continue` tokens. */
|
|
153
|
+
list(selector: ResourceSelector, options?: { namespace?: string; signal?: AbortSignal }): Promise<K8sObject[]>;
|
|
154
|
+
/** Server-side apply one object. Creates it when absent. */
|
|
155
|
+
apply(object: K8sObject, options?: ApplyOptions): Promise<K8sObject>;
|
|
156
|
+
/** Run `fn` over `items` with this client's concurrency ceiling. */
|
|
157
|
+
concurrently<T, R>(items: readonly T[], fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
158
|
+
/** The API resource lists discovery has been asked for so far, for tests and diagnostics. */
|
|
159
|
+
discoveryCacheKeys(): string[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface ApiResourceListResponse {
|
|
163
|
+
groupVersion?: string;
|
|
164
|
+
resources?: Array<{
|
|
165
|
+
name?: string;
|
|
166
|
+
singularName?: string;
|
|
167
|
+
namespaced?: boolean;
|
|
168
|
+
kind?: string;
|
|
169
|
+
verbs?: string[];
|
|
170
|
+
shortNames?: string[];
|
|
171
|
+
}>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build a client. Nothing is read from the network here — the kubeconfig is
|
|
176
|
+
* parsed, the context resolved, and the credential policy enforced, all before
|
|
177
|
+
* the first request, so a refusal happens before any exec plugin runs.
|
|
178
|
+
*/
|
|
179
|
+
export async function createK8sClient(options: K8sClientOptions = {}): Promise<K8sClient> {
|
|
180
|
+
const mod = await loadClientNode();
|
|
181
|
+
|
|
182
|
+
const kc = new mod.KubeConfig();
|
|
183
|
+
let kubeconfigSource: ClientProvenance["kubeconfigSource"];
|
|
184
|
+
if (options.kubeconfig !== undefined) {
|
|
185
|
+
kc.loadFromString(options.kubeconfig);
|
|
186
|
+
kubeconfigSource = "explicit-string";
|
|
187
|
+
} else if (options.kubeconfigPath !== undefined) {
|
|
188
|
+
kc.loadFromFile(options.kubeconfigPath);
|
|
189
|
+
kubeconfigSource = "explicit-path";
|
|
190
|
+
} else {
|
|
191
|
+
kc.loadFromDefault();
|
|
192
|
+
kubeconfigSource = kc.getCurrentContext() === "inCluster" ? "in-cluster" : "default";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (options.context !== undefined) {
|
|
196
|
+
if (!kc.getContextObject(options.context)) {
|
|
197
|
+
const known = kc.getContexts().map((c) => c.name);
|
|
198
|
+
throw new KubeConfigError(
|
|
199
|
+
`the kubeconfig has no context named "${options.context}" ` +
|
|
200
|
+
`(it has ${known.length > 0 ? known.map((n) => `"${n}"`).join(", ") : "no contexts at all"}). ` +
|
|
201
|
+
`This is the context the environment is bound to via k8s.profiles.<env>.context.`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
kc.setCurrentContext(options.context);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const cluster = kc.getCurrentCluster();
|
|
208
|
+
if (!cluster) {
|
|
209
|
+
throw new KubeConfigError(
|
|
210
|
+
`the kubeconfig resolves to no cluster for context "${kc.getCurrentContext() || "(unset)"}"`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const user = kc.getCurrentUser();
|
|
215
|
+
// Before anything is sent, and therefore before any credential plugin runs.
|
|
216
|
+
assertExecCredentialAllowed(user, options.execAllowlist ?? DEFAULT_EXEC_ALLOWLIST);
|
|
217
|
+
|
|
218
|
+
const provenance: ClientProvenance = {
|
|
219
|
+
server: cluster.server,
|
|
220
|
+
context: kc.getCurrentContext() || undefined,
|
|
221
|
+
contextSource: options.contextSource ?? "ambient",
|
|
222
|
+
kubeconfigSource,
|
|
223
|
+
...credentialPathOf(user),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const configuration = mod.createConfiguration({
|
|
227
|
+
baseServer: new mod.ServerConfiguration(cluster.server, {}),
|
|
228
|
+
authMethods: { default: kc },
|
|
229
|
+
...(options.requestLayer
|
|
230
|
+
? {
|
|
231
|
+
httpApi: mod.wrapHttpLibrary({
|
|
232
|
+
send: (request: k8s.RequestContext) =>
|
|
233
|
+
Promise.resolve(
|
|
234
|
+
options.requestLayer!.send(request as unknown as RequestContextLike),
|
|
235
|
+
) as Promise<k8s.ResponseContext>,
|
|
236
|
+
}),
|
|
237
|
+
}
|
|
238
|
+
: {}),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const defaultNamespace = kc.getContextObject(kc.getCurrentContext())?.namespace || "default";
|
|
242
|
+
const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
|
|
243
|
+
|
|
244
|
+
// apiVersion → its APIResourceList, or null when the cluster does not serve
|
|
245
|
+
// that group/version at all. Promises are cached, not values, so N entities
|
|
246
|
+
// resolved concurrently issue one discovery request between them rather
|
|
247
|
+
// than N identical ones.
|
|
248
|
+
const discoveryCache = new Map<string, Promise<ApiResourceListResponse | null>>();
|
|
249
|
+
let groupVersionsCache: Promise<string[]> | undefined;
|
|
250
|
+
|
|
251
|
+
async function send(
|
|
252
|
+
path: string,
|
|
253
|
+
method: "GET" | "PATCH" | "POST" | "PUT" | "DELETE",
|
|
254
|
+
opts: {
|
|
255
|
+
query?: Record<string, string>;
|
|
256
|
+
body?: string;
|
|
257
|
+
contentType?: string;
|
|
258
|
+
signal?: AbortSignal;
|
|
259
|
+
target?: string;
|
|
260
|
+
} = {},
|
|
261
|
+
): Promise<{ status: number; body: string }> {
|
|
262
|
+
const ctx = configuration.baseServer.makeRequestContext(path, method as k8s.HttpMethod);
|
|
263
|
+
ctx.setHeaderParam("Accept", "application/json");
|
|
264
|
+
for (const [key, value] of Object.entries(opts.query ?? {})) ctx.setQueryParam(key, value);
|
|
265
|
+
if (opts.body !== undefined) {
|
|
266
|
+
ctx.setHeaderParam("Content-Type", opts.contentType ?? "application/json");
|
|
267
|
+
ctx.setBody(opts.body);
|
|
268
|
+
}
|
|
269
|
+
if (opts.signal) ctx.setSignal(opts.signal);
|
|
270
|
+
|
|
271
|
+
// Auth runs per request: an exec plugin's token can expire mid-observation,
|
|
272
|
+
// and client-node re-invokes it only when it has to.
|
|
273
|
+
await kc.applySecurityAuthentication(ctx);
|
|
274
|
+
|
|
275
|
+
let response: ResponseContextLike;
|
|
276
|
+
try {
|
|
277
|
+
response = (await configuration.httpApi
|
|
278
|
+
.send(ctx)
|
|
279
|
+
.toPromise()) as unknown as ResponseContextLike;
|
|
280
|
+
} catch (err) {
|
|
281
|
+
throw new K8sTransportError(
|
|
282
|
+
err instanceof Error ? err.message : String(err),
|
|
283
|
+
opts.target ?? `${method} ${path}`,
|
|
284
|
+
{ cause: err },
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let text: string;
|
|
289
|
+
try {
|
|
290
|
+
text = await response.body.text();
|
|
291
|
+
} catch (err) {
|
|
292
|
+
throw new K8sTransportError(
|
|
293
|
+
`response body could not be read: ${err instanceof Error ? err.message : String(err)}`,
|
|
294
|
+
opts.target ?? `${method} ${path}`,
|
|
295
|
+
{ cause: err },
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return { status: response.httpStatusCode, body: text };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function sendJson<T>(
|
|
302
|
+
path: string,
|
|
303
|
+
method: "GET" | "PATCH" | "POST" | "PUT" | "DELETE",
|
|
304
|
+
opts: Parameters<typeof send>[2] = {},
|
|
305
|
+
): Promise<T> {
|
|
306
|
+
const { status, body } = await send(path, method, opts);
|
|
307
|
+
if (status < 200 || status > 299) {
|
|
308
|
+
throw K8sApiError.fromResponse(status, body, opts.target);
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
return JSON.parse(body) as T;
|
|
312
|
+
} catch (err) {
|
|
313
|
+
throw new K8sTransportError(
|
|
314
|
+
`the API server returned HTTP ${status} with a body that is not JSON`,
|
|
315
|
+
opts.target ?? `${method} ${path}`,
|
|
316
|
+
{ cause: err },
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function apiResourceList(apiVersion: string, signal?: AbortSignal): Promise<ApiResourceListResponse | null> {
|
|
322
|
+
const cached = discoveryCache.get(apiVersion);
|
|
323
|
+
if (cached) return cached;
|
|
324
|
+
const pending = (async () => {
|
|
325
|
+
try {
|
|
326
|
+
return await sendJson<ApiResourceListResponse>(apiVersionPath(apiVersion), "GET", {
|
|
327
|
+
signal,
|
|
328
|
+
target: `discovery ${apiVersion}`,
|
|
329
|
+
});
|
|
330
|
+
} catch (err) {
|
|
331
|
+
// A 404 on the discovery document means the cluster serves no such
|
|
332
|
+
// group/version. That is an answer, not a failure — cache it.
|
|
333
|
+
if (err instanceof K8sApiError && err.notFound) return null;
|
|
334
|
+
discoveryCache.delete(apiVersion);
|
|
335
|
+
throw err;
|
|
336
|
+
}
|
|
337
|
+
})();
|
|
338
|
+
discoveryCache.set(apiVersion, pending);
|
|
339
|
+
return pending;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function servedGroupVersions(signal?: AbortSignal): Promise<string[]> {
|
|
343
|
+
if (groupVersionsCache) return groupVersionsCache;
|
|
344
|
+
groupVersionsCache = (async () => {
|
|
345
|
+
const out: string[] = [];
|
|
346
|
+
const core = await sendJson<{ versions?: string[] }>("/api", "GET", {
|
|
347
|
+
signal,
|
|
348
|
+
target: "discovery /api",
|
|
349
|
+
});
|
|
350
|
+
out.push(...(core.versions ?? ["v1"]));
|
|
351
|
+
const groups = await sendJson<{
|
|
352
|
+
groups?: Array<{
|
|
353
|
+
name?: string;
|
|
354
|
+
preferredVersion?: { groupVersion?: string };
|
|
355
|
+
versions?: Array<{ groupVersion?: string }>;
|
|
356
|
+
}>;
|
|
357
|
+
}>("/apis", "GET", { signal, target: "discovery /apis" });
|
|
358
|
+
for (const group of groups.groups ?? []) {
|
|
359
|
+
const preferred = group.preferredVersion?.groupVersion;
|
|
360
|
+
if (preferred) out.push(preferred);
|
|
361
|
+
for (const v of group.versions ?? []) {
|
|
362
|
+
if (v.groupVersion && v.groupVersion !== preferred) out.push(v.groupVersion);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return [...new Set(out)];
|
|
366
|
+
})().catch((err: unknown) => {
|
|
367
|
+
groupVersionsCache = undefined;
|
|
368
|
+
throw err;
|
|
369
|
+
});
|
|
370
|
+
return groupVersionsCache;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function toInfo(apiVersion: string, entry: NonNullable<ApiResourceListResponse["resources"]>[number]): ApiResourceInfo {
|
|
374
|
+
const [group, version] = splitApiVersion(apiVersion);
|
|
375
|
+
return {
|
|
376
|
+
name: entry.name ?? "",
|
|
377
|
+
singularName: entry.singularName || undefined,
|
|
378
|
+
kind: entry.kind ?? "",
|
|
379
|
+
namespaced: entry.namespaced === true,
|
|
380
|
+
verbs: entry.verbs ?? [],
|
|
381
|
+
shortNames: entry.shortNames,
|
|
382
|
+
group,
|
|
383
|
+
version,
|
|
384
|
+
apiVersion,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function resolveByGvk(
|
|
389
|
+
apiVersion: string,
|
|
390
|
+
kind: string,
|
|
391
|
+
signal?: AbortSignal,
|
|
392
|
+
): Promise<ApiResourceInfo | undefined> {
|
|
393
|
+
const list = await apiResourceList(apiVersion, signal);
|
|
394
|
+
if (!list) return undefined;
|
|
395
|
+
const entry = (list.resources ?? []).find((r) => r.kind === kind && !(r.name ?? "").includes("/"));
|
|
396
|
+
return entry ? toInfo(apiVersion, entry) : undefined;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function resolveByResourceString(
|
|
400
|
+
resource: string,
|
|
401
|
+
group: string | undefined,
|
|
402
|
+
signal?: AbortSignal,
|
|
403
|
+
): Promise<ApiResourceInfo | undefined> {
|
|
404
|
+
// `kubectl get raycluster.ray.io` — everything after the first dot is the
|
|
405
|
+
// API group, which is how kubectl itself disambiguates.
|
|
406
|
+
const dot = resource.indexOf(".");
|
|
407
|
+
const bare = dot === -1 ? resource : resource.slice(0, dot);
|
|
408
|
+
const fromString = dot === -1 ? undefined : resource.slice(dot + 1);
|
|
409
|
+
const wantedGroup = fromString ?? group;
|
|
410
|
+
const needle = bare.toLowerCase();
|
|
411
|
+
|
|
412
|
+
const all = await servedGroupVersions(signal);
|
|
413
|
+
const candidates =
|
|
414
|
+
wantedGroup === undefined
|
|
415
|
+
? all
|
|
416
|
+
: all.filter((gv) => splitApiVersion(gv)[0] === (wantedGroup === "" ? "" : wantedGroup));
|
|
417
|
+
|
|
418
|
+
const lists = await mapConcurrent(
|
|
419
|
+
candidates,
|
|
420
|
+
async (gv) => ({ gv, list: await apiResourceList(gv, signal).catch(() => null) }),
|
|
421
|
+
concurrency,
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// Plural first, then singular, then kind, then short names — kubectl's own
|
|
425
|
+
// precedence, so `kubectl get certificate` and this agree.
|
|
426
|
+
const matchers: Array<(r: NonNullable<ApiResourceListResponse["resources"]>[number]) => boolean> = [
|
|
427
|
+
(r) => (r.name ?? "").toLowerCase() === needle,
|
|
428
|
+
(r) => (r.singularName ?? "").toLowerCase() === needle,
|
|
429
|
+
(r) => (r.kind ?? "").toLowerCase() === needle,
|
|
430
|
+
(r) => (r.shortNames ?? []).some((s) => s.toLowerCase() === needle),
|
|
431
|
+
];
|
|
432
|
+
for (const matches of matchers) {
|
|
433
|
+
for (const { gv, list } of lists) {
|
|
434
|
+
const entry = (list?.resources ?? []).find((r) => !(r.name ?? "").includes("/") && matches(r));
|
|
435
|
+
if (entry) return toInfo(gv, entry);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function resolve(selector: ResourceSelector, signal?: AbortSignal): Promise<ApiResourceInfo | undefined> {
|
|
442
|
+
return "apiVersion" in selector
|
|
443
|
+
? resolveByGvk(selector.apiVersion, selector.kind, signal)
|
|
444
|
+
: resolveByResourceString(selector.resource, selector.group, signal);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function resolveOrThrow(selector: ResourceSelector, signal?: AbortSignal): Promise<ApiResourceInfo> {
|
|
448
|
+
const info = await resolve(selector, signal);
|
|
449
|
+
if (!info) throw new UnknownResourceError(selectorText(selector));
|
|
450
|
+
return info;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function objectPath(info: ApiResourceInfo, name: string | undefined, namespace: string | undefined): string {
|
|
454
|
+
const parts = [apiVersionPath(info.apiVersion)];
|
|
455
|
+
if (info.namespaced) parts.push("namespaces", encodeURIComponent(namespace || defaultNamespace));
|
|
456
|
+
parts.push(info.name);
|
|
457
|
+
if (name) parts.push(encodeURIComponent(name));
|
|
458
|
+
return parts.join("/");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function read(ref: ObjectRef, opts: ReadOptions = {}): Promise<K8sObject> {
|
|
462
|
+
const info = await resolveOrThrow({ apiVersion: ref.apiVersion, kind: ref.kind }, opts.signal);
|
|
463
|
+
return sendJson<K8sObject>(objectPath(info, ref.name, ref.namespace), "GET", {
|
|
464
|
+
signal: opts.signal,
|
|
465
|
+
target: refText(ref),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function readIfPresent(ref: ObjectRef, opts: ReadOptions = {}): Promise<K8sObject | undefined> {
|
|
470
|
+
try {
|
|
471
|
+
return await read(ref, opts);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
if (err instanceof K8sApiError && err.notFound) return undefined;
|
|
474
|
+
throw err;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function list(
|
|
479
|
+
selector: ResourceSelector,
|
|
480
|
+
opts: { namespace?: string; signal?: AbortSignal } = {},
|
|
481
|
+
): Promise<K8sObject[]> {
|
|
482
|
+
const info = await resolveOrThrow(selector, opts.signal);
|
|
483
|
+
const items: K8sObject[] = [];
|
|
484
|
+
let cont: string | undefined;
|
|
485
|
+
do {
|
|
486
|
+
const page = await sendJson<{ items?: K8sObject[]; metadata?: { continue?: string } }>(
|
|
487
|
+
// Omitting the namespace segment lists across all namespaces, which is
|
|
488
|
+
// what `kubectl get <kind> -A` does and what the import path wants.
|
|
489
|
+
opts.namespace
|
|
490
|
+
? objectPath(info, undefined, opts.namespace)
|
|
491
|
+
: `${apiVersionPath(info.apiVersion)}/${info.name}`,
|
|
492
|
+
"GET",
|
|
493
|
+
{
|
|
494
|
+
signal: opts.signal,
|
|
495
|
+
query: cont ? { continue: cont } : undefined,
|
|
496
|
+
target: `list ${selectorText(selector)}`,
|
|
497
|
+
},
|
|
498
|
+
);
|
|
499
|
+
items.push(...(page.items ?? []));
|
|
500
|
+
cont = page.metadata?.continue || undefined;
|
|
501
|
+
} while (cont);
|
|
502
|
+
return items;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function apply(object: K8sObject, opts: ApplyOptions = {}): Promise<K8sObject> {
|
|
506
|
+
const apiVersion = object.apiVersion;
|
|
507
|
+
const kind = object.kind;
|
|
508
|
+
if (!apiVersion || !kind) {
|
|
509
|
+
throw new KubeConfigError(
|
|
510
|
+
`cannot apply an object without both apiVersion and kind (got apiVersion=${String(apiVersion)}, kind=${String(kind)})`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
const name = object.metadata?.name;
|
|
514
|
+
if (!name) {
|
|
515
|
+
throw new KubeConfigError(`cannot apply a ${apiVersion} ${kind} without metadata.name`);
|
|
516
|
+
}
|
|
517
|
+
const info = await resolveOrThrow({ apiVersion, kind }, opts.signal);
|
|
518
|
+
const query: Record<string, string> = {
|
|
519
|
+
fieldManager: opts.fieldManager ?? "chant",
|
|
520
|
+
force: String(opts.force ?? false),
|
|
521
|
+
};
|
|
522
|
+
if (opts.dryRun) query.dryRun = "All";
|
|
523
|
+
return sendJson<K8sObject>(objectPath(info, name, object.metadata?.namespace), "PATCH", {
|
|
524
|
+
// Server-side apply. JSON is valid YAML, so the JSON body is accepted
|
|
525
|
+
// under the apply-patch content type without a YAML round trip.
|
|
526
|
+
contentType: "application/apply-patch+yaml",
|
|
527
|
+
body: JSON.stringify(object),
|
|
528
|
+
query,
|
|
529
|
+
signal: opts.signal,
|
|
530
|
+
target: refText({ apiVersion, kind, name, namespace: object.metadata?.namespace }),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
provenance,
|
|
536
|
+
defaultNamespace,
|
|
537
|
+
resolve,
|
|
538
|
+
read,
|
|
539
|
+
readIfPresent,
|
|
540
|
+
list,
|
|
541
|
+
apply,
|
|
542
|
+
concurrently: (items, fn) => mapConcurrent(items, fn, concurrency),
|
|
543
|
+
discoveryCacheKeys: () => [...discoveryCache.keys()].sort(),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** `v1` → `/api/v1`; `apps/v1` → `/apis/apps/v1`. */
|
|
548
|
+
export function apiVersionPath(apiVersion: string): string {
|
|
549
|
+
return apiVersion.includes("/") ? `/apis/${apiVersion}` : `/api/${apiVersion}`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** `apps/v1` → `["apps", "v1"]`; `v1` → `["", "v1"]`. */
|
|
553
|
+
export function splitApiVersion(apiVersion: string): [group: string, version: string] {
|
|
554
|
+
const slash = apiVersion.indexOf("/");
|
|
555
|
+
return slash === -1 ? ["", apiVersion] : [apiVersion.slice(0, slash), apiVersion.slice(slash + 1)];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Human phrasing of a selector, for error messages. */
|
|
559
|
+
export function selectorText(selector: ResourceSelector): string {
|
|
560
|
+
return "apiVersion" in selector
|
|
561
|
+
? `${selector.apiVersion} ${selector.kind}`
|
|
562
|
+
: selector.group
|
|
563
|
+
? `${selector.resource}.${selector.group}`
|
|
564
|
+
: selector.resource;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Human phrasing of an object reference, for error messages. */
|
|
568
|
+
export function refText(ref: ObjectRef): string {
|
|
569
|
+
return `${ref.apiVersion} ${ref.kind} ${ref.namespace ? `${ref.namespace}/` : ""}${ref.name}`;
|
|
570
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded concurrency — chant #1074's "a 100-entity project is not 100 serial
|
|
3
|
+
* spawns" criterion.
|
|
4
|
+
*
|
|
5
|
+
* Unbounded is not the answer either: firing 400 requests at an API server in
|
|
6
|
+
* one tick gets the client throttled (429) or the apiserver's priority-and-
|
|
7
|
+
* fairness queue drops it, and both look like read failures rather than what
|
|
8
|
+
* they are. A small fixed window is what `kubectl` itself uses for parallel
|
|
9
|
+
* gets.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Default in-flight request ceiling. */
|
|
13
|
+
export const DEFAULT_CONCURRENCY = 8;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Map `items` through `fn` with at most `limit` running at once, preserving
|
|
17
|
+
* input order in the result. Never rejects: `fn`'s own rejections are the
|
|
18
|
+
* caller's to model (the observation path turns each into a per-entity
|
|
19
|
+
* verdict), so `fn` is expected to resolve with a discriminated outcome.
|
|
20
|
+
*/
|
|
21
|
+
export async function mapConcurrent<T, R>(
|
|
22
|
+
items: readonly T[],
|
|
23
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
24
|
+
limit: number = DEFAULT_CONCURRENCY,
|
|
25
|
+
): Promise<R[]> {
|
|
26
|
+
const width = Math.max(1, Math.min(limit, items.length));
|
|
27
|
+
const results = new Array<R>(items.length);
|
|
28
|
+
let next = 0;
|
|
29
|
+
|
|
30
|
+
async function worker(): Promise<void> {
|
|
31
|
+
while (true) {
|
|
32
|
+
const index = next++;
|
|
33
|
+
if (index >= items.length) return;
|
|
34
|
+
results[index] = await fn(items[index], index);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await Promise.all(Array.from({ length: width }, () => worker()));
|
|
39
|
+
return results;
|
|
40
|
+
}
|