@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/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
+ }