@namzu/sandbox 14.0.0 → 15.0.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +838 -0
  2. package/README.md +310 -14
  3. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  4. package/dist/backends/aci-standby-pool/index.js +13 -1
  5. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  6. package/dist/backends/docker/index.d.ts.map +1 -1
  7. package/dist/backends/docker/index.js +19 -1
  8. package/dist/backends/docker/index.js.map +1 -1
  9. package/dist/backends/firecracker/index.d.ts.map +1 -1
  10. package/dist/backends/firecracker/index.js +12 -2
  11. package/dist/backends/firecracker/index.js.map +1 -1
  12. package/dist/backends/firecracker/protocol.d.ts +459 -8
  13. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  14. package/dist/backends/firecracker/protocol.js +136 -0
  15. package/dist/backends/firecracker/protocol.js.map +1 -1
  16. package/dist/backends/firecracker/transport.d.ts +539 -6
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +1171 -24
  19. package/dist/backends/firecracker/transport.js.map +1 -1
  20. package/dist/backends/kubernetes/egress-policy.d.ts +1088 -11
  21. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  22. package/dist/backends/kubernetes/egress-policy.js +2173 -29
  23. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  24. package/dist/backends/kubernetes/identity.d.ts +193 -0
  25. package/dist/backends/kubernetes/identity.d.ts.map +1 -0
  26. package/dist/backends/kubernetes/identity.js +147 -0
  27. package/dist/backends/kubernetes/identity.js.map +1 -0
  28. package/dist/backends/kubernetes/index.d.ts +678 -33
  29. package/dist/backends/kubernetes/index.d.ts.map +1 -1
  30. package/dist/backends/kubernetes/index.js +1180 -95
  31. package/dist/backends/kubernetes/index.js.map +1 -1
  32. package/dist/backends/kubernetes/ingress-policy.d.ts +375 -0
  33. package/dist/backends/kubernetes/ingress-policy.d.ts.map +1 -0
  34. package/dist/backends/kubernetes/ingress-policy.js +1050 -0
  35. package/dist/backends/kubernetes/ingress-policy.js.map +1 -0
  36. package/dist/backends/kubernetes/k8s-client.d.ts +213 -4
  37. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -1
  38. package/dist/backends/kubernetes/k8s-client.js +359 -52
  39. package/dist/backends/kubernetes/k8s-client.js.map +1 -1
  40. package/dist/backends/kubernetes/lease.d.ts +40 -14
  41. package/dist/backends/kubernetes/lease.d.ts.map +1 -1
  42. package/dist/backends/kubernetes/lease.js +68 -18
  43. package/dist/backends/kubernetes/lease.js.map +1 -1
  44. package/dist/backends/kubernetes/objects.d.ts +423 -3
  45. package/dist/backends/kubernetes/objects.d.ts.map +1 -1
  46. package/dist/backends/kubernetes/objects.js +364 -2
  47. package/dist/backends/kubernetes/objects.js.map +1 -1
  48. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +219 -0
  49. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -0
  50. package/dist/backends/kubernetes/per-sandbox-policy.js +407 -0
  51. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -0
  52. package/dist/backends/kubernetes/rbac.d.ts +153 -0
  53. package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
  54. package/dist/backends/kubernetes/rbac.js +177 -0
  55. package/dist/backends/kubernetes/rbac.js.map +1 -0
  56. package/dist/backends/kubernetes/sandbox.d.ts +81 -14
  57. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
  58. package/dist/backends/kubernetes/sandbox.js +149 -15
  59. package/dist/backends/kubernetes/sandbox.js.map +1 -1
  60. package/dist/backends/kubernetes/transport.d.ts +935 -9
  61. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  62. package/dist/backends/kubernetes/transport.js +1958 -62
  63. package/dist/backends/kubernetes/transport.js.map +1 -1
  64. package/dist/backends/kubernetes/workspace.d.ts +1149 -18
  65. package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
  66. package/dist/backends/kubernetes/workspace.js +2825 -186
  67. package/dist/backends/kubernetes/workspace.js.map +1 -1
  68. package/dist/backends/remote-execution-controller.d.ts +14 -0
  69. package/dist/backends/remote-execution-controller.d.ts.map +1 -1
  70. package/dist/backends/remote-execution-controller.js.map +1 -1
  71. package/dist/index.d.ts +231 -13
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +247 -5
  74. package/dist/index.js.map +1 -1
  75. package/dist/testing/sandbox-conformance.d.ts +39 -5
  76. package/dist/testing/sandbox-conformance.d.ts.map +1 -1
  77. package/dist/testing/sandbox-conformance.js +436 -5
  78. package/dist/testing/sandbox-conformance.js.map +1 -1
  79. package/package.json +3 -3
  80. package/src/backends/aci-standby-pool/index.ts +16 -1
  81. package/src/backends/docker/index.ts +22 -1
  82. package/src/backends/firecracker/index.ts +14 -2
  83. package/src/backends/firecracker/protocol.ts +514 -6
  84. package/src/backends/firecracker/transport.ts +1492 -40
  85. package/src/backends/kubernetes/egress-policy.ts +3064 -53
  86. package/src/backends/kubernetes/identity.ts +261 -0
  87. package/src/backends/kubernetes/index.ts +1785 -127
  88. package/src/backends/kubernetes/ingress-policy.ts +1344 -0
  89. package/src/backends/kubernetes/k8s-client.ts +444 -54
  90. package/src/backends/kubernetes/lease.ts +75 -19
  91. package/src/backends/kubernetes/objects.ts +626 -6
  92. package/src/backends/kubernetes/per-sandbox-policy.ts +542 -0
  93. package/src/backends/kubernetes/rbac.ts +192 -0
  94. package/src/backends/kubernetes/sandbox.ts +218 -20
  95. package/src/backends/kubernetes/transport.ts +2733 -124
  96. package/src/backends/kubernetes/workspace.ts +4476 -222
  97. package/src/backends/remote-execution-controller.ts +14 -0
  98. package/src/index.ts +595 -14
  99. package/src/testing/sandbox-conformance.ts +540 -5
@@ -40,6 +40,32 @@
40
40
  * `CredentialError` naming the attempted verb and resource — never the
41
41
  * token, which this module never logs or embeds in any thrown message.
42
42
  *
43
+ * ## What a failure carries, and what it deliberately does not
44
+ * Everything else — a connect failure, and every non-2xx status the mapping
45
+ * above does not name — rejects with {@link KubernetesApiError}, which
46
+ * carries the verb, the path, the status where there was one, and the
47
+ * `Retry-After` the server sent with a 429 or a 503. That is METADATA and
48
+ * nothing more: there is no retry loop and no retry policy in this module.
49
+ * A retry is a decision about the OPERATION — a GET during a readiness poll
50
+ * may be repeated, a POST that may already have committed may not — and this
51
+ * client cannot tell those apart. `backends/kubernetes/index.ts` owns the
52
+ * policy, beside the acquire it serves and inside that acquire's existing
53
+ * deadline.
54
+ *
55
+ * ## Deadlines
56
+ * Every request carries its own bound
57
+ * ({@link KubernetesClientOptions.requestTimeoutMs}, default
58
+ * {@link DEFAULT_API_REQUEST_TIMEOUT_MS}) on top of whatever signal the
59
+ * caller passed, because the caller's signal is optional everywhere and
60
+ * several call sites are SHARED flights that run under whichever caller
61
+ * arrived first — one signal-less `destroy()` against an API server that
62
+ * accepted a request and never answered would otherwise pin every later
63
+ * caller joined to it. The bound covers `getToken()`, the connection and
64
+ * reading the body, on both transports, and expiry rejects with
65
+ * {@link KubernetesApiTimeoutError} rather than the generic `failed:` error
66
+ * so a caller can tell a timeout from a refusal. A caller's own abort still
67
+ * behaves exactly as it did.
68
+ *
43
69
  * ## Not in v1
44
70
  * No watch, no informers, no resourceVersion/bookmark tracking. Readiness is
45
71
  * polled by the caller with `../readiness.js`'s `OperationDeadline`, exactly
@@ -52,6 +78,23 @@ import https from 'node:https'
52
78
  /** The only verbs anything in this backend needs to send. */
53
79
  export type KubernetesHttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'
54
80
 
81
+ /**
82
+ * Which patch dialect a `PATCH` body is.
83
+ *
84
+ * - `merge` (the default, and what every caller sent before conditional
85
+ * writes existed) is RFC 7386 `application/merge-patch+json`: a partial
86
+ * object that recurses into maps, so a patch touching one annotation
87
+ * leaves the others standing. It has no way to express a condition.
88
+ * - `json` is RFC 6902 `application/json-patch+json`: an ordered list of
89
+ * operations, one of which is `test`. That is the whole reason it exists
90
+ * here — a `test` and the mutation it guards travel in ONE request, so
91
+ * there is no window between checking and writing. See
92
+ * `objects.ts`'s `buildHolderEpochPatch`.
93
+ *
94
+ * Omitted means `merge`, so no existing call site changes a byte.
95
+ */
96
+ export type KubernetesPatchType = 'merge' | 'json'
97
+
55
98
  /**
56
99
  * Authentication callback. Caller returns a fresh bearer token. Invoked on
57
100
  * every request so a long-running host survives token rotation — the same
@@ -91,6 +134,35 @@ export interface ExplicitKubernetesAccess {
91
134
 
92
135
  export type KubernetesAccess = InClusterKubernetesAccess | ExplicitKubernetesAccess
93
136
 
137
+ /**
138
+ * Bound every request this client sends is measured against, on top of the
139
+ * caller's own signal. Orthogonal to {@link KubernetesAccess}, which says
140
+ * WHO the client is, so it is a second argument rather than another arm of
141
+ * that union.
142
+ */
143
+ export interface KubernetesClientOptions {
144
+ /**
145
+ * Milliseconds a single request may take, end to end: resolving the
146
+ * token, connecting, sending, and reading the reply. Default
147
+ * {@link DEFAULT_API_REQUEST_TIMEOUT_MS}; minimum
148
+ * {@link MIN_API_REQUEST_TIMEOUT_MS}. There is deliberately NO value
149
+ * that turns the bound off — see {@link resolveRequestTimeoutMs}.
150
+ */
151
+ readonly requestTimeoutMs?: number
152
+ }
153
+
154
+ /**
155
+ * Default {@link KubernetesClientOptions.requestTimeoutMs} — 30 s.
156
+ *
157
+ * The same cap `lease.ts` already puts on a renewal PATCH, and below the
158
+ * 60 s `readyTimeoutMs` default, so a request that times out still leaves
159
+ * the readiness budget something to report with.
160
+ */
161
+ export const DEFAULT_API_REQUEST_TIMEOUT_MS = 30_000
162
+
163
+ /** Floor for {@link KubernetesClientOptions.requestTimeoutMs} — 1 s. */
164
+ export const MIN_API_REQUEST_TIMEOUT_MS = 1_000
165
+
94
166
  export interface KubernetesClient {
95
167
  /**
96
168
  * `path` is the API-server path (e.g.
@@ -98,14 +170,22 @@ export interface KubernetesClient {
98
170
  * URL. Returns the parsed JSON body, or `undefined` for a 204 or an empty
99
171
  * body. Rejects with {@link KubernetesAlreadyGoneError},
100
172
  * {@link KubernetesConflictError} or {@link KubernetesCredentialError} for
101
- * the status codes each names; any other non-2xx status rejects with a
102
- * plain `Error`.
173
+ * the status codes each names, and with {@link KubernetesApiTimeoutError}
174
+ * when the request outlives
175
+ * {@link KubernetesClientOptions.requestTimeoutMs}; a connect failure and
176
+ * any other non-2xx status reject with {@link KubernetesApiError}.
177
+ *
178
+ * `patchType` picks the dialect a `PATCH` body is sent as, and is ignored
179
+ * for every other verb. `json` additionally maps a 422 to
180
+ * {@link KubernetesPatchNotAppliedError} — see that class for why the
181
+ * status alone is all the API server gives anyone to go on.
103
182
  */
104
183
  request<T>(
105
184
  method: KubernetesHttpMethod,
106
185
  path: string,
107
186
  body?: unknown,
108
187
  signal?: AbortSignal,
188
+ patchType?: KubernetesPatchType,
109
189
  ): Promise<T | undefined>
110
190
  /** From the ServiceAccount file in-cluster, from config otherwise. */
111
191
  namespace(): string
@@ -136,6 +216,52 @@ export class KubernetesConflictError extends Error {
136
216
  }
137
217
  }
138
218
 
219
+ /**
220
+ * A JSON Patch the API server would not apply: 422 Unprocessable Entity.
221
+ *
222
+ * ## What the API server actually says, measured
223
+ *
224
+ * Against a real API server (v1.37.0) and the agent-sandbox `Sandbox` CRD, a
225
+ * JSON Patch whose `test` clause does not hold answers **422** with this
226
+ * body, byte for byte:
227
+ *
228
+ * ```json
229
+ * {"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure",
230
+ * "message":"the server rejected our request due to an error in our request",
231
+ * "reason":"Invalid","details":{},"code":422}
232
+ * ```
233
+ *
234
+ * A patch that is simply WRONG — a pointer into a member that does not
235
+ * exist, say — answers with exactly the same status, the same reason and the
236
+ * same message. The server does not name the operation that failed, so
237
+ * nothing this class could read off the response would tell a lost race from
238
+ * a malformed body, and a class that claimed to would be lying.
239
+ *
240
+ * So it says what it can honestly say: the patch did not apply and NOTHING
241
+ * was changed. Deciding which of the two it was belongs to the caller that
242
+ * knows what it tested — `workspace.ts` re-reads the object and compares the
243
+ * value it tested against what is stored now: changed ⇒ somebody raced it,
244
+ * unchanged ⇒ the body is wrong and the error is rethrown rather than
245
+ * retried forever.
246
+ *
247
+ * Not a {@link KubernetesConflictError}: that one is 409, which the same
248
+ * server returns for a DELETE whose `preconditions.resourceVersion` does not
249
+ * match (measured on the same cluster, with a message that DOES name the two
250
+ * versions). The two statuses mean different things and are kept apart.
251
+ */
252
+ export class KubernetesPatchNotAppliedError extends Error {
253
+ constructor(
254
+ readonly method: KubernetesHttpMethod,
255
+ readonly resource: string,
256
+ readonly status: number,
257
+ ) {
258
+ super(
259
+ `kubernetes ${method} ${resource} -> ${status}: the API server did not apply the JSON patch and changed nothing. It does not say which operation failed, so this is either a \`test\` clause that no longer holds — another holder wrote the object first — or a patch body that is wrong.`,
260
+ )
261
+ this.name = 'KubernetesPatchNotAppliedError'
262
+ }
263
+ }
264
+
139
265
  /**
140
266
  * 401/403 → this. Names the attempted verb and resource only — never the
141
267
  * token, and never the response body (the API server does not echo the
@@ -153,6 +279,181 @@ export class KubernetesCredentialError extends Error {
153
279
  }
154
280
  }
155
281
 
282
+ /**
283
+ * The request outlived {@link KubernetesClientOptions.requestTimeoutMs}.
284
+ *
285
+ * Deliberately NOT the generic `failed:` error: a caller that can tell a
286
+ * timeout from a refusal can retry an idempotent write, and one that cannot
287
+ * has to treat every failure alike. Carries the verb, the resource path and
288
+ * the bound that expired — and, like every other error in this module,
289
+ * never the bearer token.
290
+ *
291
+ * A timeout says nothing about whether the request was APPLIED, which is
292
+ * why nothing here tries to: a suspend restores the state it saw and sends
293
+ * its idempotent patch again, a create POST that landed is adopted through
294
+ * the 409 path, and a DELETE that already applied counts as done.
295
+ */
296
+ export class KubernetesApiTimeoutError extends Error {
297
+ constructor(
298
+ readonly verb: KubernetesHttpMethod,
299
+ readonly resource: string,
300
+ readonly timeoutMs: number,
301
+ ) {
302
+ super(
303
+ `kubernetes ${verb} ${resource} was still unanswered ${timeoutMs}ms after it was sent, and was given up on (apiRequestTimeoutMs). Whether the API server applied it is unknown. Raise apiRequestTimeoutMs if this cluster is genuinely this slow.`,
304
+ )
305
+ this.name = 'KubernetesApiTimeoutError'
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Which half of a request failed, and therefore what is known about it.
311
+ *
312
+ * - `'connect'` — the request never got an answer: DNS, TCP, TLS or a reset
313
+ * socket. Whether the API server saw it at all is unknown.
314
+ * - `'status'` — the API server answered, with a status this client's
315
+ * mapping does not name. `status` is then always present.
316
+ */
317
+ export type KubernetesApiFailureTransport = 'connect' | 'status'
318
+
319
+ /**
320
+ * Every API failure this client does not already name: a connect failure, and
321
+ * every non-2xx status outside 401/403/404/409/410 (and the 422 a JSON patch
322
+ * gets).
323
+ *
324
+ * It exists because "a burst past node capacity" and "the API server is down"
325
+ * used to be the same plain `Error`, separable only by matching the message
326
+ * text a release is free to reword. The fields are the smallest set that
327
+ * makes the difference decidable by a caller:
328
+ *
329
+ * - `status` — absent for a connect failure, present for every answered one.
330
+ * - `retryAfterMs` — the `Retry-After` header the API server sends with a
331
+ * 429 (its own priority-and-fairness queue shedding load) and sometimes
332
+ * with a 503, normalised to milliseconds from either spelling the header
333
+ * allows. Absent when the server sent none, which is not the same as zero.
334
+ * - `transport` — see {@link KubernetesApiFailureTransport}.
335
+ * - `cause` — the underlying error for a connect failure.
336
+ *
337
+ * **There is no retry here.** This class is metadata; `index.ts` decides what
338
+ * is worth repeating, because only the caller knows whether the operation is
339
+ * idempotent and only the caller owns a clock to repeat it inside.
340
+ *
341
+ * The message keeps the exact wording both throw sites used before, so a host
342
+ * that logs it sees no change and only a host that inspects the type gains
343
+ * anything.
344
+ */
345
+ export class KubernetesApiError extends Error {
346
+ readonly method: KubernetesHttpMethod
347
+ readonly path: string
348
+ readonly status?: number
349
+ readonly retryAfterMs?: number
350
+ readonly transport: KubernetesApiFailureTransport
351
+
352
+ constructor(
353
+ message: string,
354
+ details: {
355
+ readonly method: KubernetesHttpMethod
356
+ readonly path: string
357
+ readonly status?: number
358
+ readonly retryAfterMs?: number
359
+ readonly transport: KubernetesApiFailureTransport
360
+ readonly cause?: unknown
361
+ },
362
+ ) {
363
+ super(message, details.cause !== undefined ? { cause: details.cause } : undefined)
364
+ this.name = 'KubernetesApiError'
365
+ this.method = details.method
366
+ this.path = details.path
367
+ if (details.status !== undefined) this.status = details.status
368
+ if (details.retryAfterMs !== undefined) this.retryAfterMs = details.retryAfterMs
369
+ this.transport = details.transport
370
+ }
371
+ }
372
+
373
+ /**
374
+ * `Retry-After`, in milliseconds, or `undefined` when the server sent none or
375
+ * sent one that cannot be read.
376
+ *
377
+ * RFC 9110 allows two spellings and the API server uses the first: a
378
+ * delta-seconds integer (`Retry-After: 1`), which is what
379
+ * priority-and-fairness sends with a 429. The HTTP-date spelling is accepted
380
+ * too, because a proxy in front of the API server may rewrite it. A date in
381
+ * the past, or a negative delta, reads as `0` — "now" — rather than being
382
+ * discarded, because the server still said to wait and the answer to "how
383
+ * long" is "no longer".
384
+ */
385
+ export function parseRetryAfterMs(header: string | undefined): number | undefined {
386
+ if (header === undefined) return undefined
387
+ const raw = header.trim()
388
+ if (raw === '') return undefined
389
+ if (/^-?\d+$/.test(raw)) {
390
+ const seconds = Number(raw)
391
+ if (!Number.isSafeInteger(seconds)) return undefined
392
+ return Math.max(0, seconds * 1_000)
393
+ }
394
+ const at = Date.parse(raw)
395
+ if (Number.isNaN(at)) return undefined
396
+ return Math.max(0, at - Date.now())
397
+ }
398
+
399
+ /**
400
+ * Validate the configured bound, or supply the default.
401
+ *
402
+ * There is no disabling value, and `0` is refused rather than read as
403
+ * "unbounded": an unanswered request is not a supported configuration.
404
+ * Several call sites are single-flight promises that run under the FIRST
405
+ * caller's signal and never consult a later one's, so one hung request
406
+ * does not block one caller — it blocks every caller that joined it, on a
407
+ * handle whose state already refuses data-plane calls. A cluster with a
408
+ * genuinely slow API server raises the number.
409
+ */
410
+ export function resolveRequestTimeoutMs(value: number | undefined): number {
411
+ if (value === undefined) return DEFAULT_API_REQUEST_TIMEOUT_MS
412
+ if (!Number.isSafeInteger(value) || value < MIN_API_REQUEST_TIMEOUT_MS) {
413
+ throw new Error(
414
+ `kubernetes: apiRequestTimeoutMs must be an integer of at least ${MIN_API_REQUEST_TIMEOUT_MS}ms, got ${JSON.stringify(
415
+ value,
416
+ )}. No value disables the bound: a request the API server accepted and never answered would hang its caller — and every later caller joined to the same single-flight suspend, delete or verification — until the process was killed. Raise the number instead; the default is ${DEFAULT_API_REQUEST_TIMEOUT_MS}ms.`,
417
+ )
418
+ }
419
+ return value
420
+ }
421
+
422
+ /**
423
+ * Await `promise`, but give up the moment `signal` aborts, rejecting with
424
+ * that signal's reason.
425
+ *
426
+ * `getToken()` is a caller-supplied callback and `res.text()` is a body the
427
+ * peer may simply stop sending, and neither takes a signal of its own, so
428
+ * without this the bound would cover only the part of a request this module
429
+ * happens to hold a socket for. The late settlement is swallowed rather
430
+ * than left dangling: an unobserved rejection is still a rejection.
431
+ */
432
+ function settleOnAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
433
+ if (signal.aborted) {
434
+ void promise.catch(() => {})
435
+ return Promise.reject(signal.reason)
436
+ }
437
+ return new Promise<T>((resolve, reject) => {
438
+ let done = false
439
+ const onAbort = () => {
440
+ done = true
441
+ reject(signal.reason)
442
+ }
443
+ signal.addEventListener('abort', onAbort, { once: true })
444
+ promise.then(
445
+ (value) => {
446
+ signal.removeEventListener('abort', onAbort)
447
+ if (!done) resolve(value)
448
+ },
449
+ (error: unknown) => {
450
+ signal.removeEventListener('abort', onAbort)
451
+ if (!done) reject(error)
452
+ },
453
+ )
454
+ })
455
+ }
456
+
156
457
  function stripTrailingSlash(url: string): string {
157
458
  return url.endsWith('/') ? url.slice(0, -1) : url
158
459
  }
@@ -201,6 +502,16 @@ function resolveExplicit(access: ExplicitKubernetesAccess): ResolvedAccess {
201
502
  interface RawResponse {
202
503
  readonly status: number
203
504
  readonly contentType: string
505
+ /**
506
+ * One response header, by lowercase name, or `undefined`.
507
+ *
508
+ * Exactly one header is read through it — `Retry-After` — and it is a
509
+ * lookup rather than the whole header bag on purpose: the two transports
510
+ * below spell a header bag differently (`Headers` versus a
511
+ * `string | string[]` record), and a caller that had to cope with both
512
+ * would be the third place in this file that knows which transport ran.
513
+ */
514
+ readonly header: (name: string) => string | undefined
204
515
  readonly text: () => Promise<string>
205
516
  }
206
517
 
@@ -219,6 +530,7 @@ async function fetchRequest(
219
530
  return {
220
531
  status: res.status,
221
532
  contentType: res.headers.get('content-type') ?? '',
533
+ header: (name) => res.headers.get(name) ?? undefined,
222
534
  text: () => res.text(),
223
535
  }
224
536
  }
@@ -261,6 +573,10 @@ function httpsRequest(
261
573
  resolve({
262
574
  status: res.statusCode ?? 0,
263
575
  contentType: Array.isArray(ct) ? (ct[0] ?? '') : (ct ?? ''),
576
+ header: (name) => {
577
+ const value = res.headers[name]
578
+ return Array.isArray(value) ? value[0] : value
579
+ },
264
580
  text: async () => Buffer.concat(chunks).toString('utf8'),
265
581
  })
266
582
  })
@@ -274,75 +590,149 @@ function httpsRequest(
274
590
  }
275
591
 
276
592
  /**
277
- * `PATCH` always carries a JSON MERGE patch body (RFC 7386) — never
593
+ * `PATCH` carries a JSON MERGE patch body (RFC 7386) unless the caller asked
594
+ * for `json`, which sends an RFC 6902 operation list instead — never
278
595
  * server-side apply, never YAML. `POST` carries a plain JSON create body.
279
- * `GET`/`DELETE` normally carry no body; if a caller ever does pass one to
280
- * `DELETE` it is sent as plain JSON.
596
+ * `GET`/`DELETE` normally carry no body; when a caller does pass one to
597
+ * `DELETE` — a `DeleteOptions` with `preconditions` — it is sent as plain
598
+ * JSON, which is what the API server expects there.
281
599
  */
282
- function contentTypeFor(method: KubernetesHttpMethod): string {
283
- return method === 'PATCH' ? 'application/merge-patch+json' : 'application/json'
600
+ function contentTypeFor(method: KubernetesHttpMethod, patchType: KubernetesPatchType): string {
601
+ if (method !== 'PATCH') return 'application/json'
602
+ return patchType === 'json' ? 'application/json-patch+json' : 'application/merge-patch+json'
284
603
  }
285
604
 
286
- export function createKubernetesClient(access: KubernetesAccess): KubernetesClient {
605
+ export function createKubernetesClient(
606
+ access: KubernetesAccess,
607
+ options: KubernetesClientOptions = {},
608
+ ): KubernetesClient {
287
609
  const resolved = access.inCluster === true ? resolveInCluster(access) : resolveExplicit(access)
610
+ // Validated at construction, not at the first request: a configuration
611
+ // this module will never honour should be refused where the operator can
612
+ // still see which backend it came from.
613
+ const requestTimeoutMs = resolveRequestTimeoutMs(options.requestTimeoutMs)
288
614
 
289
615
  async function request<T>(
290
616
  method: KubernetesHttpMethod,
291
617
  path: string,
292
618
  body?: unknown,
293
619
  signal?: AbortSignal,
620
+ patchType: KubernetesPatchType = 'merge',
294
621
  ): Promise<T | undefined> {
295
622
  signal?.throwIfAborted()
296
- const token = await resolved.getToken()
297
- signal?.throwIfAborted()
298
- const url = `${resolved.baseUrl}${path}`
299
- const payload = body !== undefined ? JSON.stringify(body) : undefined
300
- const headers: Record<string, string> = {
301
- Authorization: `Bearer ${token}`,
302
- Accept: 'application/json',
303
- }
304
- if (payload !== undefined) headers['content-type'] = contentTypeFor(method)
623
+ // The caller's signal is WRAPPED, never replaced: `deadline` aborts
624
+ // with the caller's own reason when the caller aborts, so every path
625
+ // below behaves exactly as it did, and with a
626
+ // KubernetesApiTimeoutError when the bound expires first. Combined by
627
+ // hand rather than with `AbortSignal.any`, which this package's
628
+ // declared Node floor (20.0) predates.
629
+ const deadlineController = new AbortController()
630
+ const deadline = deadlineController.signal
631
+ let timedOut = false
632
+ const timer = setTimeout(() => {
633
+ timedOut = true
634
+ deadlineController.abort(new KubernetesApiTimeoutError(method, path, requestTimeoutMs))
635
+ }, requestTimeoutMs)
636
+ timer.unref?.()
637
+ const forwardCallerAbort = () => deadlineController.abort(signal?.reason)
638
+ signal?.addEventListener('abort', forwardCallerAbort, { once: true })
639
+ /** True while the CALLER is the one who gave up, which wins. */
640
+ const callerGaveUp = () => signal?.aborted === true
641
+ const readBody = async (res: RawResponse): Promise<string> =>
642
+ await settleOnAbort(res.text(), deadline)
305
643
 
306
- let res: RawResponse
307
644
  try {
308
- res =
309
- resolved.ca !== undefined
310
- ? await httpsRequest(url, method, headers, payload, resolved.ca, signal)
311
- : await fetchRequest(url, method, headers, payload, signal)
312
- } catch (err) {
313
- throw new Error(
314
- `kubernetes ${method} ${path} failed: ${err instanceof Error ? err.message : String(err)}`,
315
- { cause: err },
316
- )
317
- }
645
+ // Covered by the bound at last: `getToken()` is caller code that
646
+ // takes no signal of its own, and an in-cluster token read that
647
+ // blocks on a wedged volume used to be unbounded on every request.
648
+ const token = await settleOnAbort(resolved.getToken(), deadline)
649
+ deadline.throwIfAborted()
650
+ const url = `${resolved.baseUrl}${path}`
651
+ const payload = body !== undefined ? JSON.stringify(body) : undefined
652
+ const headers: Record<string, string> = {
653
+ Authorization: `Bearer ${token}`,
654
+ Accept: 'application/json',
655
+ }
656
+ if (payload !== undefined) headers['content-type'] = contentTypeFor(method, patchType)
318
657
 
319
- if (res.status === 401 || res.status === 403) {
320
- await res.text()
321
- signal?.throwIfAborted()
322
- throw new KubernetesCredentialError(method, path, res.status)
323
- }
324
- if (res.status === 404 || res.status === 410) {
325
- await res.text()
326
- signal?.throwIfAborted()
327
- throw new KubernetesAlreadyGoneError(method, path, res.status)
328
- }
329
- if (res.status === 409) {
330
- await res.text()
331
- signal?.throwIfAborted()
332
- throw new KubernetesConflictError(method, path)
333
- }
334
- if (res.status < 200 || res.status >= 300) {
335
- const text = await res.text()
336
- signal?.throwIfAborted()
337
- throw new Error(`kubernetes ${method} ${path} -> ${res.status}: ${text}`)
338
- }
339
- if (res.status === 204) return undefined
340
- if (res.contentType.includes('application/json')) {
341
- const text = await res.text()
342
- signal?.throwIfAborted()
343
- return text.length > 0 ? (JSON.parse(text) as T) : undefined
658
+ let res: RawResponse
659
+ try {
660
+ res =
661
+ resolved.ca !== undefined
662
+ ? await httpsRequest(url, method, headers, payload, resolved.ca, deadline)
663
+ : await fetchRequest(url, method, headers, payload, deadline)
664
+ } catch (err) {
665
+ if (err instanceof KubernetesApiTimeoutError) throw err
666
+ // A timeout is named rather than folded into `failed:`; a
667
+ // caller's own abort still produces the wrapped error it
668
+ // always produced.
669
+ if (timedOut && !callerGaveUp()) {
670
+ throw new KubernetesApiTimeoutError(method, path, requestTimeoutMs)
671
+ }
672
+ // Same sentence it has always thrown, on a class that says which
673
+ // half failed. Nothing here decides whether to try again: see
674
+ // {@link KubernetesApiError}.
675
+ throw new KubernetesApiError(
676
+ `kubernetes ${method} ${path} failed: ${err instanceof Error ? err.message : String(err)}`,
677
+ { method, path, transport: 'connect', cause: err },
678
+ )
679
+ }
680
+
681
+ if (res.status === 401 || res.status === 403) {
682
+ await readBody(res)
683
+ deadline.throwIfAborted()
684
+ throw new KubernetesCredentialError(method, path, res.status)
685
+ }
686
+ if (res.status === 404 || res.status === 410) {
687
+ await readBody(res)
688
+ deadline.throwIfAborted()
689
+ throw new KubernetesAlreadyGoneError(method, path, res.status)
690
+ }
691
+ if (res.status === 409) {
692
+ await readBody(res)
693
+ deadline.throwIfAborted()
694
+ throw new KubernetesConflictError(method, path)
695
+ }
696
+ // Before the generic mapping, and only for the dialect that has a
697
+ // `test` clause to fail: a merge patch has no condition, so a 422
698
+ // on one is a malformed body and belongs in the generic error
699
+ // where it always was.
700
+ if (res.status === 422 && method === 'PATCH' && patchType === 'json') {
701
+ await readBody(res)
702
+ deadline.throwIfAborted()
703
+ throw new KubernetesPatchNotAppliedError(method, path, res.status)
704
+ }
705
+ if (res.status < 200 || res.status >= 300) {
706
+ // Read BEFORE the body, because `readBody` can abort out of this
707
+ // block and the header is the whole reason a caller can wait the
708
+ // length the server asked for rather than a length it invented.
709
+ // Only for the two statuses that mean "later, not never": a 400
710
+ // carrying one would be the server contradicting itself.
711
+ const retryAfterMs =
712
+ res.status === 429 || res.status === 503
713
+ ? parseRetryAfterMs(res.header('retry-after'))
714
+ : undefined
715
+ const text = await readBody(res)
716
+ deadline.throwIfAborted()
717
+ throw new KubernetesApiError(`kubernetes ${method} ${path} -> ${res.status}: ${text}`, {
718
+ method,
719
+ path,
720
+ status: res.status,
721
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
722
+ transport: 'status',
723
+ })
724
+ }
725
+ if (res.status === 204) return undefined
726
+ if (res.contentType.includes('application/json')) {
727
+ const text = await readBody(res)
728
+ deadline.throwIfAborted()
729
+ return text.length > 0 ? (JSON.parse(text) as T) : undefined
730
+ }
731
+ return undefined
732
+ } finally {
733
+ clearTimeout(timer)
734
+ signal?.removeEventListener('abort', forwardCallerAbort)
344
735
  }
345
- return undefined
346
736
  }
347
737
 
348
738
  return {