@namzu/sandbox 13.0.0 → 14.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 (67) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/README.md +151 -0
  3. package/dist/backends/firecracker/protocol.d.ts +22 -0
  4. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  5. package/dist/backends/firecracker/protocol.js.map +1 -1
  6. package/dist/backends/firecracker/transport.d.ts +104 -9
  7. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  8. package/dist/backends/firecracker/transport.js +139 -13
  9. package/dist/backends/firecracker/transport.js.map +1 -1
  10. package/dist/backends/kubernetes/egress-policy.d.ts +219 -0
  11. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -0
  12. package/dist/backends/kubernetes/egress-policy.js +314 -0
  13. package/dist/backends/kubernetes/egress-policy.js.map +1 -0
  14. package/dist/backends/kubernetes/index.d.ts +374 -0
  15. package/dist/backends/kubernetes/index.d.ts.map +1 -0
  16. package/dist/backends/kubernetes/index.js +671 -0
  17. package/dist/backends/kubernetes/index.js.map +1 -0
  18. package/dist/backends/kubernetes/k8s-client.d.ts +125 -0
  19. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -0
  20. package/dist/backends/kubernetes/k8s-client.js +246 -0
  21. package/dist/backends/kubernetes/k8s-client.js.map +1 -0
  22. package/dist/backends/kubernetes/lease.d.ts +119 -0
  23. package/dist/backends/kubernetes/lease.d.ts.map +1 -0
  24. package/dist/backends/kubernetes/lease.js +151 -0
  25. package/dist/backends/kubernetes/lease.js.map +1 -0
  26. package/dist/backends/kubernetes/objects.d.ts +282 -0
  27. package/dist/backends/kubernetes/objects.d.ts.map +1 -0
  28. package/dist/backends/kubernetes/objects.js +156 -0
  29. package/dist/backends/kubernetes/objects.js.map +1 -0
  30. package/dist/backends/kubernetes/privilege-probe.d.ts +136 -0
  31. package/dist/backends/kubernetes/privilege-probe.d.ts.map +1 -0
  32. package/dist/backends/kubernetes/privilege-probe.js +185 -0
  33. package/dist/backends/kubernetes/privilege-probe.js.map +1 -0
  34. package/dist/backends/kubernetes/sandbox.d.ts +123 -0
  35. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -0
  36. package/dist/backends/kubernetes/sandbox.js +299 -0
  37. package/dist/backends/kubernetes/sandbox.js.map +1 -0
  38. package/dist/backends/kubernetes/transport.d.ts +122 -0
  39. package/dist/backends/kubernetes/transport.d.ts.map +1 -0
  40. package/dist/backends/kubernetes/transport.js +197 -0
  41. package/dist/backends/kubernetes/transport.js.map +1 -0
  42. package/dist/backends/kubernetes/workspace.d.ts +381 -0
  43. package/dist/backends/kubernetes/workspace.d.ts.map +1 -0
  44. package/dist/backends/kubernetes/workspace.js +1064 -0
  45. package/dist/backends/kubernetes/workspace.js.map +1 -0
  46. package/dist/index.d.ts +132 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +102 -34
  49. package/dist/index.js.map +1 -1
  50. package/dist/testing/sandbox-conformance.d.ts +193 -0
  51. package/dist/testing/sandbox-conformance.d.ts.map +1 -0
  52. package/dist/testing/sandbox-conformance.js +465 -0
  53. package/dist/testing/sandbox-conformance.js.map +1 -0
  54. package/package.json +5 -4
  55. package/src/backends/firecracker/protocol.ts +27 -0
  56. package/src/backends/firecracker/transport.ts +199 -28
  57. package/src/backends/kubernetes/egress-policy.ts +437 -0
  58. package/src/backends/kubernetes/index.ts +1012 -0
  59. package/src/backends/kubernetes/k8s-client.ts +352 -0
  60. package/src/backends/kubernetes/lease.ts +198 -0
  61. package/src/backends/kubernetes/objects.ts +363 -0
  62. package/src/backends/kubernetes/privilege-probe.ts +261 -0
  63. package/src/backends/kubernetes/sandbox.ts +395 -0
  64. package/src/backends/kubernetes/transport.ts +286 -0
  65. package/src/backends/kubernetes/workspace.ts +1386 -0
  66. package/src/index.ts +257 -35
  67. package/src/testing/sandbox-conformance.ts +667 -0
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Minimal Kubernetes API client: bare fetch/https transport plus in-cluster
3
+ * ServiceAccount bootstrap.
4
+ *
5
+ * Sibling of the ACI and Firecracker control-plane clients: same
6
+ * dependency-free shape, different API. No `@kubernetes/client-node`, no
7
+ * kubeconfig parsing, no exec-credential-plugin invocation, no YAML anywhere
8
+ * — `@namzu/sandbox` still declares zero runtime `dependencies` after this
9
+ * module.
10
+ *
11
+ * ## Auth / SDK-dependency boundary
12
+ * Two access sources, neither needing a YAML parser:
13
+ * - `{ inCluster: true }` reads the projected ServiceAccount volume
14
+ * (`token`, `ca.crt`, `namespace`) plus `KUBERNETES_SERVICE_HOST` /
15
+ * `KUBERNETES_SERVICE_PORT` — pure file + env reads, the same boundary
16
+ * `kubectl` itself uses inside a pod. The token file is re-read on EVERY
17
+ * request because kubelet rotates projected tokens under the pod; caching
18
+ * it would produce an intermittent 401 hours into a long-lived host
19
+ * process.
20
+ * - `{ server, ca?, getToken }` supplied by the caller mirrors ACI's
21
+ * `ArmTokenProvider` and Firecracker's `OrchestratorTokenProvider`
22
+ * (`../aci-standby-pool/index.ts`, `../firecracker/index.ts`). Kubeconfig
23
+ * parsing, context merging and exec-credential-plugin invocation
24
+ * (kubelogin, gcloud, aws-iam-authenticator, ...) all stay OUTSIDE this
25
+ * package; the caller resolves a bearer token however it likes.
26
+ *
27
+ * ## Transport
28
+ * Default path is bare global `fetch`. When a custom CA is present — always
29
+ * true in-cluster, since the cluster CA is never in the process's system
30
+ * trust store — the call goes through `node:https.request` with that `ca`
31
+ * and `rejectUnauthorized: true` instead, exactly as
32
+ * `../firecracker/index.ts` splits `fetchOrchestratorRequest` from
33
+ * `httpsOrchestratorRequest` ("the package declares no undici dependency").
34
+ *
35
+ * ## Status mapping
36
+ * 404/410 become an `AlreadyGoneError` sentinel a teardown path can treat as
37
+ * success (ACI's `armCall(..., [404, 410])` accepts the same pair for
38
+ * exactly that reason — see `../aci-standby-pool/index.ts`). 409 becomes a
39
+ * `ConflictError` an adoption/retry path can catch. 401/403 become a
40
+ * `CredentialError` naming the attempted verb and resource — never the
41
+ * token, which this module never logs or embeds in any thrown message.
42
+ *
43
+ * ## Not in v1
44
+ * No watch, no informers, no resourceVersion/bookmark tracking. Readiness is
45
+ * polled by the caller with `../readiness.js`'s `OperationDeadline`, exactly
46
+ * as ACI polls `provisioningState`.
47
+ */
48
+
49
+ import { readFileSync } from 'node:fs'
50
+ import https from 'node:https'
51
+
52
+ /** The only verbs anything in this backend needs to send. */
53
+ export type KubernetesHttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'
54
+
55
+ /**
56
+ * Authentication callback. Caller returns a fresh bearer token. Invoked on
57
+ * every request so a long-running host survives token rotation — the same
58
+ * contract as ACI's `ArmTokenProvider` and Firecracker's
59
+ * `OrchestratorTokenProvider`.
60
+ */
61
+ export type KubernetesTokenProvider = () => Promise<string>
62
+
63
+ /**
64
+ * In-cluster bootstrap. The token, CA and namespace come off the projected
65
+ * ServiceAccount volume; the API server address comes off the env vars the
66
+ * kubelet always sets for the pod's default-namespace Service.
67
+ */
68
+ export interface InClusterKubernetesAccess {
69
+ readonly inCluster: true
70
+ /**
71
+ * Overridable so tests never touch the real projected-volume path.
72
+ * Defaults to `/var/run/secrets/kubernetes.io/serviceaccount`.
73
+ */
74
+ readonly serviceAccountDir?: string
75
+ }
76
+
77
+ /**
78
+ * Caller-supplied server + credential (see {@link KubernetesTokenProvider}).
79
+ * `namespace` is required here because, unlike the in-cluster path, there is
80
+ * no ServiceAccount file to read it from — the caller must say which
81
+ * namespace this client operates in.
82
+ */
83
+ export interface ExplicitKubernetesAccess {
84
+ readonly inCluster?: false
85
+ readonly server: string
86
+ readonly namespace: string
87
+ /** Custom cluster CA. Present → the client dials over `node:https`. */
88
+ readonly ca?: string | Buffer
89
+ readonly getToken: KubernetesTokenProvider
90
+ }
91
+
92
+ export type KubernetesAccess = InClusterKubernetesAccess | ExplicitKubernetesAccess
93
+
94
+ export interface KubernetesClient {
95
+ /**
96
+ * `path` is the API-server path (e.g.
97
+ * `/apis/agents.x-k8s.io/v1/namespaces/ns/sandboxclaims/id`), not a full
98
+ * URL. Returns the parsed JSON body, or `undefined` for a 204 or an empty
99
+ * body. Rejects with {@link KubernetesAlreadyGoneError},
100
+ * {@link KubernetesConflictError} or {@link KubernetesCredentialError} for
101
+ * the status codes each names; any other non-2xx status rejects with a
102
+ * plain `Error`.
103
+ */
104
+ request<T>(
105
+ method: KubernetesHttpMethod,
106
+ path: string,
107
+ body?: unknown,
108
+ signal?: AbortSignal,
109
+ ): Promise<T | undefined>
110
+ /** From the ServiceAccount file in-cluster, from config otherwise. */
111
+ namespace(): string
112
+ }
113
+
114
+ const DEFAULT_SERVICE_ACCOUNT_DIR = '/var/run/secrets/kubernetes.io/serviceaccount'
115
+
116
+ /** 404/410 → this. A teardown path treats it as "already achieved". */
117
+ export class KubernetesAlreadyGoneError extends Error {
118
+ constructor(
119
+ readonly method: KubernetesHttpMethod,
120
+ readonly resource: string,
121
+ readonly status: number,
122
+ ) {
123
+ super(`kubernetes ${method} ${resource} -> ${status}: already gone`)
124
+ this.name = 'KubernetesAlreadyGoneError'
125
+ }
126
+ }
127
+
128
+ /** 409 → this. An adoption/retry path can catch it and re-read + retry. */
129
+ export class KubernetesConflictError extends Error {
130
+ constructor(
131
+ readonly method: KubernetesHttpMethod,
132
+ readonly resource: string,
133
+ ) {
134
+ super(`kubernetes ${method} ${resource} -> 409: conflict`)
135
+ this.name = 'KubernetesConflictError'
136
+ }
137
+ }
138
+
139
+ /**
140
+ * 401/403 → this. Names the attempted verb and resource only — never the
141
+ * token, and never the response body (the API server does not echo the
142
+ * token back, but the body is untrusted content this module has no reason
143
+ * to repeat into a thrown message).
144
+ */
145
+ export class KubernetesCredentialError extends Error {
146
+ constructor(
147
+ readonly verb: KubernetesHttpMethod,
148
+ readonly resource: string,
149
+ readonly status: number,
150
+ ) {
151
+ super(`kubernetes API refused ${verb} ${resource}: ${status}`)
152
+ this.name = 'KubernetesCredentialError'
153
+ }
154
+ }
155
+
156
+ function stripTrailingSlash(url: string): string {
157
+ return url.endsWith('/') ? url.slice(0, -1) : url
158
+ }
159
+
160
+ interface ResolvedAccess {
161
+ readonly baseUrl: string
162
+ readonly ca?: string | Buffer
163
+ readonly namespace: string
164
+ readonly getToken: KubernetesTokenProvider
165
+ }
166
+
167
+ /**
168
+ * Reads `ca.crt` and `namespace` once (they do not rotate for the pod's
169
+ * lifetime); returns a `getToken` closure that re-reads `token` on every
170
+ * call, because kubelet DOES rotate the projected token under a live pod.
171
+ */
172
+ function resolveInCluster(access: InClusterKubernetesAccess): ResolvedAccess {
173
+ const dir = access.serviceAccountDir ?? DEFAULT_SERVICE_ACCOUNT_DIR
174
+ const host = process.env.KUBERNETES_SERVICE_HOST
175
+ const port = process.env.KUBERNETES_SERVICE_PORT
176
+ if (host === undefined || host === '' || port === undefined || port === '') {
177
+ throw new Error(
178
+ 'kubernetes: in-cluster access requires KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT',
179
+ )
180
+ }
181
+ const ca = readFileSync(`${dir}/ca.crt`)
182
+ const namespace = readFileSync(`${dir}/namespace`, 'utf8').trim()
183
+ return {
184
+ baseUrl: `https://${host}:${port}`,
185
+ ca,
186
+ namespace,
187
+ getToken: async () => readFileSync(`${dir}/token`, 'utf8').trim(),
188
+ }
189
+ }
190
+
191
+ function resolveExplicit(access: ExplicitKubernetesAccess): ResolvedAccess {
192
+ return {
193
+ baseUrl: stripTrailingSlash(access.server),
194
+ ca: access.ca,
195
+ namespace: access.namespace,
196
+ getToken: access.getToken,
197
+ }
198
+ }
199
+
200
+ /** A transport-agnostic view of the API-server response the mapping needs. */
201
+ interface RawResponse {
202
+ readonly status: number
203
+ readonly contentType: string
204
+ readonly text: () => Promise<string>
205
+ }
206
+
207
+ /** The plain-`fetch` path — the DEFAULT, used whenever no custom CA is set. */
208
+ async function fetchRequest(
209
+ url: string,
210
+ method: KubernetesHttpMethod,
211
+ headers: Record<string, string>,
212
+ payload: string | undefined,
213
+ signal?: AbortSignal,
214
+ ): Promise<RawResponse> {
215
+ const init: RequestInit = { method, headers }
216
+ if (payload !== undefined) init.body = payload
217
+ if (signal !== undefined) init.signal = signal
218
+ const res = await fetch(url, init)
219
+ return {
220
+ status: res.status,
221
+ contentType: res.headers.get('content-type') ?? '',
222
+ text: () => res.text(),
223
+ }
224
+ }
225
+
226
+ /**
227
+ * The custom-CA path: a `node:https` request presenting the injected `ca`
228
+ * and verifying the API server's cert against it (`rejectUnauthorized:
229
+ * true`). `node:https` is used rather than fetch+a custom dispatcher because
230
+ * the package declares no undici dependency — `node:https` is always
231
+ * importable and needs nothing added (mirrors
232
+ * `../firecracker/index.ts`'s `httpsOrchestratorRequest`).
233
+ */
234
+ function httpsRequest(
235
+ url: string,
236
+ method: KubernetesHttpMethod,
237
+ headers: Record<string, string>,
238
+ payload: string | undefined,
239
+ ca: string | Buffer,
240
+ signal?: AbortSignal,
241
+ ): Promise<RawResponse> {
242
+ const target = new URL(url)
243
+ return new Promise<RawResponse>((resolve, reject) => {
244
+ const req = https.request(
245
+ {
246
+ protocol: target.protocol,
247
+ hostname: target.hostname,
248
+ port: target.port !== '' ? Number(target.port) : 443,
249
+ path: `${target.pathname}${target.search}`,
250
+ method,
251
+ headers,
252
+ ca,
253
+ rejectUnauthorized: true,
254
+ ...(signal !== undefined ? { signal } : {}),
255
+ },
256
+ (res) => {
257
+ const chunks: Buffer[] = []
258
+ res.on('data', (chunk: Buffer) => chunks.push(chunk))
259
+ res.on('end', () => {
260
+ const ct = res.headers['content-type']
261
+ resolve({
262
+ status: res.statusCode ?? 0,
263
+ contentType: Array.isArray(ct) ? (ct[0] ?? '') : (ct ?? ''),
264
+ text: async () => Buffer.concat(chunks).toString('utf8'),
265
+ })
266
+ })
267
+ res.on('error', reject)
268
+ },
269
+ )
270
+ req.on('error', reject)
271
+ if (payload !== undefined) req.write(payload)
272
+ req.end()
273
+ })
274
+ }
275
+
276
+ /**
277
+ * `PATCH` always carries a JSON MERGE patch body (RFC 7386) — never
278
+ * 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.
281
+ */
282
+ function contentTypeFor(method: KubernetesHttpMethod): string {
283
+ return method === 'PATCH' ? 'application/merge-patch+json' : 'application/json'
284
+ }
285
+
286
+ export function createKubernetesClient(access: KubernetesAccess): KubernetesClient {
287
+ const resolved = access.inCluster === true ? resolveInCluster(access) : resolveExplicit(access)
288
+
289
+ async function request<T>(
290
+ method: KubernetesHttpMethod,
291
+ path: string,
292
+ body?: unknown,
293
+ signal?: AbortSignal,
294
+ ): Promise<T | undefined> {
295
+ 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)
305
+
306
+ let res: RawResponse
307
+ 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
+ }
318
+
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
344
+ }
345
+ return undefined
346
+ }
347
+
348
+ return {
349
+ request,
350
+ namespace: () => resolved.namespace,
351
+ }
352
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Lease renewal for an acquired kubernetes sandbox.
3
+ *
4
+ * Acquire stamps an ABSOLUTE `shutdownTime` (now + `claimTtlSeconds`) plus
5
+ * `shutdownPolicy: Delete` onto every object it creates. That is the leak
6
+ * guard: a host that dies mid-run costs the cluster one expiry rather than a
7
+ * sandbox that lives forever. It is also, unrenewed, a deadline on the RUN —
8
+ * a session that outlives the TTL has its pod deleted underneath it, mid
9
+ * command, with no error the host can attribute to anything.
10
+ *
11
+ * The two requirements are not in tension, they just need a second half:
12
+ * a live handle renews its own lease, and a dead host stops renewing. So
13
+ * this timer is owned by the Sandbox handle, and `destroy()` stops it.
14
+ *
15
+ * ## The shape of the timer
16
+ *
17
+ * One `setTimeout` chained per tick, never `setInterval`: a renewal PATCH
18
+ * that takes longer than the interval must not queue a second one behind
19
+ * it. The interval is HALF the TTL, so a single failed tick still leaves a
20
+ * whole half-TTL of headroom for the next one to succeed, and it is
21
+ * jittered ±10% so a hundred handles acquired in the same second do not
22
+ * PATCH the API server in the same millisecond forever after.
23
+ *
24
+ * The timer is `unref`'d: a host process that has finished its work should
25
+ * exit, not linger because a sandbox handle is still counting. A handle
26
+ * nobody destroyed then expires on the cluster's clock exactly as an
27
+ * abandoned one does, which is the behaviour the TTL exists for.
28
+ *
29
+ * ## Every tick is bounded
30
+ *
31
+ * A renewal that FAILS is survivable — it is reported and retried with half
32
+ * a TTL of headroom. A renewal that HANGS is not: the next tick is scheduled
33
+ * only after the current one settles, so a PATCH that never answers parks
34
+ * the loop forever, reports nothing, and lets the lease expire in silence —
35
+ * precisely the defect this file exists to close, moved onto the failure
36
+ * path. An API server that accepts a connection and then never responds is
37
+ * an ordinary cluster event, so each PATCH runs under its own deadline: it
38
+ * aborts the request through the signal the client already takes, and an
39
+ * expiry is then just another reported failure that retries on the next
40
+ * tick.
41
+ *
42
+ * ## What each outcome means
43
+ *
44
+ * - Success → the object's expiry moves a full TTL into the future.
45
+ * - Any error, a tick that ran out of time included → reported to
46
+ * `onRenewalError` and RETRIED on the next tick. A transient API blip
47
+ * must not tear down a working sandbox, and there is still half a TTL of
48
+ * headroom.
49
+ * - Already gone (404/410) → the object this handle owns no longer exists.
50
+ * Nothing will bring it back, so the loop stops and the handle is marked
51
+ * gone; every later call fails with a named error instead of dialing an
52
+ * address whose pod the controller has already deleted.
53
+ */
54
+
55
+ import { OperationDeadline } from '../readiness.js'
56
+ import { KubernetesAlreadyGoneError } from './k8s-client.js'
57
+
58
+ /** How far each tick pushes the expiry, and how often ticks happen. */
59
+ export interface LeaseRenewalOptions {
60
+ /** The same TTL acquire stamped. Each tick sets `now + ttlSeconds`. */
61
+ readonly ttlSeconds: number
62
+ /**
63
+ * Send the merge patch. Rejects with whatever the client rejects with.
64
+ *
65
+ * The `signal` is the tick's own deadline and IS passed on every call —
66
+ * an implementation that drops it still gets abandoned on time, but its
67
+ * socket then stays open until the peer or the OS closes it.
68
+ */
69
+ readonly renew: (shutdownTime: string, signal?: AbortSignal) => Promise<void>
70
+ /** Called once, when the renewed object turns out to be gone. */
71
+ readonly onGone: () => void
72
+ /**
73
+ * Every renewal failure that is not "already gone". `@namzu/sandbox` has
74
+ * no logger of its own and reads none from module scope, so a diagnostic
75
+ * this package cannot print is handed to the caller that can.
76
+ */
77
+ readonly onRenewalError?: (error: unknown) => void
78
+ /**
79
+ * Base interval between ticks. Defaults to half the TTL. Present so a
80
+ * test can drive many ticks in a few milliseconds without pretending a
81
+ * sub-second TTL is a realistic configuration.
82
+ */
83
+ readonly intervalMs?: number
84
+ /**
85
+ * How long ONE renewal PATCH may take before it is abandoned and retried.
86
+ * Defaults to a quarter of the interval, capped at
87
+ * {@link MAX_RENEWAL_TIMEOUT_MS} — a fraction rather than the whole
88
+ * interval so that a stalled API server still leaves the loop several
89
+ * attempts inside the half-TTL of headroom.
90
+ */
91
+ readonly patchTimeoutMs?: number
92
+ /** Deterministic jitter for tests. Defaults to `Math.random`. */
93
+ readonly random?: () => number
94
+ }
95
+
96
+ /**
97
+ * The ceiling on one renewal PATCH. A write to the API server that has not
98
+ * answered in half a minute is not going to; at the one-hour default TTL the
99
+ * derived quarter-interval would otherwise be 7.5 minutes of silence.
100
+ */
101
+ const MAX_RENEWAL_TIMEOUT_MS = 30_000
102
+
103
+ /** ±10%: enough to spread a synchronised fleet, far too little to matter
104
+ * against a half-TTL of headroom. */
105
+ const JITTER_FRACTION = 0.1
106
+
107
+ export function jitteredInterval(baseMs: number, random: () => number): number {
108
+ const factor = 1 - JITTER_FRACTION + random() * (2 * JITTER_FRACTION)
109
+ // Never zero, whatever a caller passes: a zero-delay chain would spin.
110
+ return Math.max(1, Math.round(baseMs * factor))
111
+ }
112
+
113
+ /**
114
+ * The renewal loop. Start it when the handle is handed out, stop it on
115
+ * `destroy()`. Both are idempotent.
116
+ */
117
+ export class KubernetesLeaseRenewal {
118
+ private timer: ReturnType<typeof setTimeout> | undefined
119
+ private stopped = false
120
+ private started = false
121
+ private readonly baseIntervalMs: number
122
+ private readonly patchTimeoutMs: number
123
+ private readonly random: () => number
124
+
125
+ constructor(private readonly options: LeaseRenewalOptions) {
126
+ this.baseIntervalMs = options.intervalMs ?? Math.max(1, (options.ttlSeconds * 1_000) / 2)
127
+ this.patchTimeoutMs =
128
+ options.patchTimeoutMs ??
129
+ Math.max(1, Math.min(MAX_RENEWAL_TIMEOUT_MS, Math.round(this.baseIntervalMs / 4)))
130
+ this.random = options.random ?? Math.random
131
+ }
132
+
133
+ /**
134
+ * The loop is alive: it has been started and not stopped. Deliberately
135
+ * NOT "a timer is pending" — `tick()` clears the timer before it awaits,
136
+ * so a liveness check written that way reads false for the whole duration
137
+ * of an in-flight renewal and would quietly pass against a loop that had
138
+ * parked forever inside one.
139
+ */
140
+ get active(): boolean {
141
+ return !this.stopped && this.started
142
+ }
143
+
144
+ start(): void {
145
+ if (this.stopped || this.timer !== undefined) return
146
+ this.started = true
147
+ this.schedule()
148
+ }
149
+
150
+ stop(): void {
151
+ this.stopped = true
152
+ if (this.timer !== undefined) {
153
+ clearTimeout(this.timer)
154
+ this.timer = undefined
155
+ }
156
+ }
157
+
158
+ private schedule(): void {
159
+ if (this.stopped) return
160
+ const timer = setTimeout(
161
+ () => {
162
+ void this.tick()
163
+ },
164
+ jitteredInterval(this.baseIntervalMs, this.random),
165
+ )
166
+ // A pending renewal must never be the reason a host process stays up.
167
+ timer.unref?.()
168
+ this.timer = timer
169
+ }
170
+
171
+ /** Exposed for tests: one renewal attempt plus its scheduling decision. */
172
+ async tick(): Promise<void> {
173
+ this.timer = undefined
174
+ if (this.stopped) return
175
+ const shutdownTime = new Date(Date.now() + this.options.ttlSeconds * 1_000).toISOString()
176
+ try {
177
+ // On its own clock: the next tick is scheduled only once this one
178
+ // settles, so an unbounded PATCH that never answers would park the
179
+ // loop permanently and let the lease expire with nothing reported.
180
+ // The deadline aborts the request and hands the expiry to the same
181
+ // report-and-retry path every other failure takes.
182
+ await new OperationDeadline(this.patchTimeoutMs, 'kubernetes lease renewal').run(
183
+ async (signal) => await this.options.renew(shutdownTime, signal),
184
+ )
185
+ } catch (error) {
186
+ if (error instanceof KubernetesAlreadyGoneError) {
187
+ this.stop()
188
+ this.options.onGone()
189
+ return
190
+ }
191
+ // Everything else is transient until proven otherwise: report it
192
+ // and try again on the next tick, which is still half a TTL
193
+ // before anything expires.
194
+ this.options.onRenewalError?.(error)
195
+ }
196
+ this.schedule()
197
+ }
198
+ }