@namzu/sandbox 13.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.
- package/CHANGELOG.md +1147 -0
- package/README.md +447 -0
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +13 -1
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +19 -1
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +12 -2
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/backends/firecracker/protocol.d.ts +481 -8
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js +136 -0
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +642 -14
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +1307 -34
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/kubernetes/egress-policy.d.ts +1296 -0
- package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -0
- package/dist/backends/kubernetes/egress-policy.js +2458 -0
- package/dist/backends/kubernetes/egress-policy.js.map +1 -0
- package/dist/backends/kubernetes/identity.d.ts +193 -0
- package/dist/backends/kubernetes/identity.d.ts.map +1 -0
- package/dist/backends/kubernetes/identity.js +147 -0
- package/dist/backends/kubernetes/identity.js.map +1 -0
- package/dist/backends/kubernetes/index.d.ts +1019 -0
- package/dist/backends/kubernetes/index.d.ts.map +1 -0
- package/dist/backends/kubernetes/index.js +1756 -0
- package/dist/backends/kubernetes/index.js.map +1 -0
- package/dist/backends/kubernetes/ingress-policy.d.ts +375 -0
- package/dist/backends/kubernetes/ingress-policy.d.ts.map +1 -0
- package/dist/backends/kubernetes/ingress-policy.js +1050 -0
- package/dist/backends/kubernetes/ingress-policy.js.map +1 -0
- package/dist/backends/kubernetes/k8s-client.d.ts +334 -0
- package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -0
- package/dist/backends/kubernetes/k8s-client.js +553 -0
- package/dist/backends/kubernetes/k8s-client.js.map +1 -0
- package/dist/backends/kubernetes/lease.d.ts +145 -0
- package/dist/backends/kubernetes/lease.d.ts.map +1 -0
- package/dist/backends/kubernetes/lease.js +201 -0
- package/dist/backends/kubernetes/lease.js.map +1 -0
- package/dist/backends/kubernetes/objects.d.ts +702 -0
- package/dist/backends/kubernetes/objects.d.ts.map +1 -0
- package/dist/backends/kubernetes/objects.js +518 -0
- package/dist/backends/kubernetes/objects.js.map +1 -0
- package/dist/backends/kubernetes/per-sandbox-policy.d.ts +219 -0
- package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -0
- package/dist/backends/kubernetes/per-sandbox-policy.js +407 -0
- package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -0
- package/dist/backends/kubernetes/privilege-probe.d.ts +136 -0
- package/dist/backends/kubernetes/privilege-probe.d.ts.map +1 -0
- package/dist/backends/kubernetes/privilege-probe.js +185 -0
- package/dist/backends/kubernetes/privilege-probe.js.map +1 -0
- package/dist/backends/kubernetes/rbac.d.ts +153 -0
- package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
- package/dist/backends/kubernetes/rbac.js +177 -0
- package/dist/backends/kubernetes/rbac.js.map +1 -0
- package/dist/backends/kubernetes/sandbox.d.ts +190 -0
- package/dist/backends/kubernetes/sandbox.d.ts.map +1 -0
- package/dist/backends/kubernetes/sandbox.js +433 -0
- package/dist/backends/kubernetes/sandbox.js.map +1 -0
- package/dist/backends/kubernetes/transport.d.ts +1048 -0
- package/dist/backends/kubernetes/transport.d.ts.map +1 -0
- package/dist/backends/kubernetes/transport.js +2093 -0
- package/dist/backends/kubernetes/transport.js.map +1 -0
- package/dist/backends/kubernetes/workspace.d.ts +1512 -0
- package/dist/backends/kubernetes/workspace.d.ts.map +1 -0
- package/dist/backends/kubernetes/workspace.js +3703 -0
- package/dist/backends/kubernetes/workspace.js.map +1 -0
- package/dist/backends/remote-execution-controller.d.ts +14 -0
- package/dist/backends/remote-execution-controller.d.ts.map +1 -1
- package/dist/backends/remote-execution-controller.js.map +1 -1
- package/dist/index.d.ts +350 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +344 -34
- package/dist/index.js.map +1 -1
- package/dist/testing/sandbox-conformance.d.ts +227 -0
- package/dist/testing/sandbox-conformance.d.ts.map +1 -0
- package/dist/testing/sandbox-conformance.js +896 -0
- package/dist/testing/sandbox-conformance.js.map +1 -0
- package/package.json +5 -4
- package/src/backends/aci-standby-pool/index.ts +16 -1
- package/src/backends/docker/index.ts +22 -1
- package/src/backends/firecracker/index.ts +14 -2
- package/src/backends/firecracker/protocol.ts +541 -6
- package/src/backends/firecracker/transport.ts +1687 -64
- package/src/backends/kubernetes/egress-policy.ts +3448 -0
- package/src/backends/kubernetes/identity.ts +261 -0
- package/src/backends/kubernetes/index.ts +2670 -0
- package/src/backends/kubernetes/ingress-policy.ts +1344 -0
- package/src/backends/kubernetes/k8s-client.ts +742 -0
- package/src/backends/kubernetes/lease.ts +254 -0
- package/src/backends/kubernetes/objects.ts +983 -0
- package/src/backends/kubernetes/per-sandbox-policy.ts +542 -0
- package/src/backends/kubernetes/privilege-probe.ts +261 -0
- package/src/backends/kubernetes/rbac.ts +192 -0
- package/src/backends/kubernetes/sandbox.ts +593 -0
- package/src/backends/kubernetes/transport.ts +2895 -0
- package/src/backends/kubernetes/workspace.ts +5640 -0
- package/src/backends/remote-execution-controller.ts +14 -0
- package/src/index.ts +838 -35
- package/src/testing/sandbox-conformance.ts +1202 -0
|
@@ -0,0 +1,254 @@
|
|
|
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. On SUCCESS the interval is HALF the TTL, jittered ±10% so a hundred
|
|
20
|
+
* handles acquired in the same second do not PATCH the API server in the
|
|
21
|
+
* same millisecond forever after.
|
|
22
|
+
*
|
|
23
|
+
* The timer is `unref`'d: a host process that has finished its work should
|
|
24
|
+
* exit, not linger because a sandbox handle is still counting. A handle
|
|
25
|
+
* nobody destroyed then expires on the cluster's clock exactly as an
|
|
26
|
+
* abandoned one does, which is the behaviour the TTL exists for.
|
|
27
|
+
*
|
|
28
|
+
* ## A failed tick does not wait for the next half-TTL
|
|
29
|
+
*
|
|
30
|
+
* Waiting a full half-TTL before retrying a FAILED renewal means one blip at
|
|
31
|
+
* exactly the wrong moment is a coin flip against the object's own
|
|
32
|
+
* `shutdownTime`: the retry and the expiry are both roughly a TTL after the
|
|
33
|
+
* last success, so a single failure can lose that race. A failed tick
|
|
34
|
+
* instead retries on capped exponential backoff — starting at one second,
|
|
35
|
+
* doubling, capped at whichever is smaller of thirty seconds or a
|
|
36
|
+
* twentieth of the TTL — so an outage around a scheduled renewal gets many
|
|
37
|
+
* attempts inside the window that actually matters, not one. Every success
|
|
38
|
+
* resets the backoff and returns the loop to the normal half-TTL cadence.
|
|
39
|
+
*
|
|
40
|
+
* ## Every tick is bounded
|
|
41
|
+
*
|
|
42
|
+
* A renewal that FAILS is survivable — it is reported and retried on a
|
|
43
|
+
* short backoff. A renewal that HANGS is not: the next tick is scheduled
|
|
44
|
+
* only after the current one settles, so a PATCH that never answers parks
|
|
45
|
+
* the loop forever, reports nothing, and lets the lease expire in silence —
|
|
46
|
+
* precisely the defect this file exists to close, moved onto the failure
|
|
47
|
+
* path. An API server that accepts a connection and then never responds is
|
|
48
|
+
* an ordinary cluster event, so each PATCH runs under its own deadline: it
|
|
49
|
+
* aborts the request through the signal the client already takes, and an
|
|
50
|
+
* expiry is then just another reported failure that retries on backoff.
|
|
51
|
+
*
|
|
52
|
+
* ## What each outcome means
|
|
53
|
+
*
|
|
54
|
+
* - Success → the object's expiry moves a full TTL into the future, the
|
|
55
|
+
* backoff resets, and the next tick is a half-TTL away again.
|
|
56
|
+
* - Any error, a tick that ran out of time included → reported to
|
|
57
|
+
* `onRenewalError` and RETRIED on a backoff far shorter than the
|
|
58
|
+
* half-TTL interval. A transient API blip must not tear down a working
|
|
59
|
+
* sandbox, and the loop keeps trying rather than spend the object's
|
|
60
|
+
* remaining headroom waiting.
|
|
61
|
+
* - Already gone (404/410) → the object this handle owns no longer exists.
|
|
62
|
+
* Nothing will bring it back, so the loop stops and the handle is marked
|
|
63
|
+
* gone; every later call fails with a named error instead of dialing an
|
|
64
|
+
* address whose pod the controller has already deleted.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import { OperationDeadline } from '../readiness.js'
|
|
68
|
+
import { KubernetesAlreadyGoneError } from './k8s-client.js'
|
|
69
|
+
|
|
70
|
+
/** How far each tick pushes the expiry, and how often ticks happen. */
|
|
71
|
+
export interface LeaseRenewalOptions {
|
|
72
|
+
/** The same TTL acquire stamped. Each tick sets `now + ttlSeconds`. */
|
|
73
|
+
readonly ttlSeconds: number
|
|
74
|
+
/**
|
|
75
|
+
* Send the merge patch. Rejects with whatever the client rejects with.
|
|
76
|
+
*
|
|
77
|
+
* The `signal` is the tick's own deadline and IS passed on every call —
|
|
78
|
+
* an implementation that drops it still gets abandoned on time, but its
|
|
79
|
+
* socket then stays open until the peer or the OS closes it.
|
|
80
|
+
*/
|
|
81
|
+
readonly renew: (shutdownTime: string, signal?: AbortSignal) => Promise<void>
|
|
82
|
+
/** Called once, when the renewed object turns out to be gone. */
|
|
83
|
+
readonly onGone: () => void
|
|
84
|
+
/**
|
|
85
|
+
* Every renewal failure that is not "already gone". `@namzu/sandbox` has
|
|
86
|
+
* no logger of its own and reads none from module scope, so a diagnostic
|
|
87
|
+
* this package cannot print is handed to the caller that can.
|
|
88
|
+
*/
|
|
89
|
+
readonly onRenewalError?: (error: unknown) => void
|
|
90
|
+
/**
|
|
91
|
+
* Base interval between ticks. Defaults to half the TTL. Present so a
|
|
92
|
+
* test can drive many ticks in a few milliseconds without pretending a
|
|
93
|
+
* sub-second TTL is a realistic configuration.
|
|
94
|
+
*/
|
|
95
|
+
readonly intervalMs?: number
|
|
96
|
+
/**
|
|
97
|
+
* How long ONE renewal PATCH may take before it is abandoned and retried.
|
|
98
|
+
* Defaults to a quarter of the interval, capped at
|
|
99
|
+
* {@link MAX_RENEWAL_TIMEOUT_MS} — a fraction rather than the whole
|
|
100
|
+
* interval so that a stalled API server still leaves the loop several
|
|
101
|
+
* backoff-paced attempts before the next regular half-TTL tick.
|
|
102
|
+
*/
|
|
103
|
+
readonly patchTimeoutMs?: number
|
|
104
|
+
/** Deterministic jitter for tests. Defaults to `Math.random`. */
|
|
105
|
+
readonly random?: () => number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The ceiling on one renewal PATCH. A write to the API server that has not
|
|
110
|
+
* answered in half a minute is not going to; at the one-hour default TTL the
|
|
111
|
+
* derived quarter-interval would otherwise be 7.5 minutes of silence.
|
|
112
|
+
*/
|
|
113
|
+
const MAX_RENEWAL_TIMEOUT_MS = 30_000
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The floor of the retry backoff after a failed renewal: one second. Far
|
|
117
|
+
* short of the half-TTL interval, on purpose — a failure needs another
|
|
118
|
+
* chance long before the object's `shutdownTime` is at risk, not after
|
|
119
|
+
* waiting as long as a successful tick would have.
|
|
120
|
+
*/
|
|
121
|
+
const RETRY_BACKOFF_FLOOR_MS = 1_000
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The ceiling of the retry backoff, whichever is smaller: thirty seconds, or
|
|
125
|
+
* a twentieth of the TTL. The TTL fraction keeps a short-TTL sandbox (tests,
|
|
126
|
+
* mainly) from retrying so slowly that the backoff alone could still lose
|
|
127
|
+
* the race against expiry; thirty seconds keeps an hour-plus TTL from
|
|
128
|
+
* retrying needlessly often once the ceiling is reached.
|
|
129
|
+
*/
|
|
130
|
+
const MAX_RETRY_BACKOFF_MS = 30_000
|
|
131
|
+
|
|
132
|
+
/** ±10%: enough to spread a synchronised fleet, far too little to matter
|
|
133
|
+
* against a half-TTL of headroom. */
|
|
134
|
+
const JITTER_FRACTION = 0.1
|
|
135
|
+
|
|
136
|
+
export function jitteredInterval(baseMs: number, random: () => number): number {
|
|
137
|
+
const factor = 1 - JITTER_FRACTION + random() * (2 * JITTER_FRACTION)
|
|
138
|
+
// Never zero, whatever a caller passes: a zero-delay chain would spin.
|
|
139
|
+
return Math.max(1, Math.round(baseMs * factor))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The renewal loop. Start it when the handle is handed out, stop it on
|
|
144
|
+
* `destroy()`. Both are idempotent.
|
|
145
|
+
*/
|
|
146
|
+
export class KubernetesLeaseRenewal {
|
|
147
|
+
private timer: ReturnType<typeof setTimeout> | undefined
|
|
148
|
+
private stopped = false
|
|
149
|
+
private started = false
|
|
150
|
+
private readonly baseIntervalMs: number
|
|
151
|
+
private readonly patchTimeoutMs: number
|
|
152
|
+
private readonly retryBackoffCapMs: number
|
|
153
|
+
private readonly random: () => number
|
|
154
|
+
/**
|
|
155
|
+
* Non-gone failures since the last success (or since the loop started).
|
|
156
|
+
* Reset to 0 by every success; drives how far the next retry backs off.
|
|
157
|
+
*/
|
|
158
|
+
private consecutiveFailures = 0
|
|
159
|
+
|
|
160
|
+
constructor(private readonly options: LeaseRenewalOptions) {
|
|
161
|
+
this.baseIntervalMs = options.intervalMs ?? Math.max(1, (options.ttlSeconds * 1_000) / 2)
|
|
162
|
+
this.patchTimeoutMs =
|
|
163
|
+
options.patchTimeoutMs ??
|
|
164
|
+
Math.max(1, Math.min(MAX_RENEWAL_TIMEOUT_MS, Math.round(this.baseIntervalMs / 4)))
|
|
165
|
+
this.retryBackoffCapMs = Math.max(
|
|
166
|
+
1,
|
|
167
|
+
Math.min(MAX_RETRY_BACKOFF_MS, (options.ttlSeconds * 1_000) / 20),
|
|
168
|
+
)
|
|
169
|
+
this.random = options.random ?? Math.random
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The loop is alive: it has been started and not stopped. Deliberately
|
|
174
|
+
* NOT "a timer is pending" — `tick()` clears the timer before it awaits,
|
|
175
|
+
* so a liveness check written that way reads false for the whole duration
|
|
176
|
+
* of an in-flight renewal and would quietly pass against a loop that had
|
|
177
|
+
* parked forever inside one.
|
|
178
|
+
*/
|
|
179
|
+
get active(): boolean {
|
|
180
|
+
return !this.stopped && this.started
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
start(): void {
|
|
184
|
+
if (this.stopped || this.timer !== undefined) return
|
|
185
|
+
this.started = true
|
|
186
|
+
this.scheduleNext(this.baseIntervalMs)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
stop(): void {
|
|
190
|
+
this.stopped = true
|
|
191
|
+
if (this.timer !== undefined) {
|
|
192
|
+
clearTimeout(this.timer)
|
|
193
|
+
this.timer = undefined
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private scheduleNext(baseMs: number): void {
|
|
198
|
+
if (this.stopped) return
|
|
199
|
+
const timer = setTimeout(
|
|
200
|
+
() => {
|
|
201
|
+
void this.tick()
|
|
202
|
+
},
|
|
203
|
+
jitteredInterval(baseMs, this.random),
|
|
204
|
+
)
|
|
205
|
+
// A pending renewal must never be the reason a host process stays up.
|
|
206
|
+
timer.unref?.()
|
|
207
|
+
this.timer = timer
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The delay before the NEXT retry after a non-gone failure: capped
|
|
212
|
+
* exponential backoff from {@link RETRY_BACKOFF_FLOOR_MS}, doubling on
|
|
213
|
+
* every consecutive failure, ceilinged at {@link retryBackoffCapMs}.
|
|
214
|
+
* Called only once `consecutiveFailures` has already been incremented for
|
|
215
|
+
* the failure that just happened, so the first retry uses the floor.
|
|
216
|
+
*/
|
|
217
|
+
private retryDelayMs(): number {
|
|
218
|
+
const doubled = RETRY_BACKOFF_FLOOR_MS * 2 ** (this.consecutiveFailures - 1)
|
|
219
|
+
return Math.min(this.retryBackoffCapMs, doubled)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Exposed for tests: one renewal attempt plus its scheduling decision. */
|
|
223
|
+
async tick(): Promise<void> {
|
|
224
|
+
this.timer = undefined
|
|
225
|
+
if (this.stopped) return
|
|
226
|
+
const shutdownTime = new Date(Date.now() + this.options.ttlSeconds * 1_000).toISOString()
|
|
227
|
+
try {
|
|
228
|
+
// On its own clock: the next tick is scheduled only once this one
|
|
229
|
+
// settles, so an unbounded PATCH that never answers would park the
|
|
230
|
+
// loop permanently and let the lease expire with nothing reported.
|
|
231
|
+
// The deadline aborts the request and hands the expiry to the same
|
|
232
|
+
// report-and-retry path every other failure takes.
|
|
233
|
+
await new OperationDeadline(this.patchTimeoutMs, 'kubernetes lease renewal').run(
|
|
234
|
+
async (signal) => await this.options.renew(shutdownTime, signal),
|
|
235
|
+
)
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error instanceof KubernetesAlreadyGoneError) {
|
|
238
|
+
this.stop()
|
|
239
|
+
this.options.onGone()
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
// Everything else is transient until proven otherwise: report it
|
|
243
|
+
// and retry on a backoff far shorter than the half-TTL interval —
|
|
244
|
+
// a coin-flip race against the object's own expiry is exactly what
|
|
245
|
+
// this file exists to avoid.
|
|
246
|
+
this.consecutiveFailures += 1
|
|
247
|
+
this.options.onRenewalError?.(error)
|
|
248
|
+
this.scheduleNext(this.retryDelayMs())
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
this.consecutiveFailures = 0
|
|
252
|
+
this.scheduleNext(this.baseIntervalMs)
|
|
253
|
+
}
|
|
254
|
+
}
|