@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +1147 -0
  2. package/README.md +447 -0
  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 +481 -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 +642 -14
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +1307 -34
  19. package/dist/backends/firecracker/transport.js.map +1 -1
  20. package/dist/backends/kubernetes/egress-policy.d.ts +1296 -0
  21. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -0
  22. package/dist/backends/kubernetes/egress-policy.js +2458 -0
  23. package/dist/backends/kubernetes/egress-policy.js.map +1 -0
  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 +1019 -0
  29. package/dist/backends/kubernetes/index.d.ts.map +1 -0
  30. package/dist/backends/kubernetes/index.js +1756 -0
  31. package/dist/backends/kubernetes/index.js.map +1 -0
  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 +334 -0
  37. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -0
  38. package/dist/backends/kubernetes/k8s-client.js +553 -0
  39. package/dist/backends/kubernetes/k8s-client.js.map +1 -0
  40. package/dist/backends/kubernetes/lease.d.ts +145 -0
  41. package/dist/backends/kubernetes/lease.d.ts.map +1 -0
  42. package/dist/backends/kubernetes/lease.js +201 -0
  43. package/dist/backends/kubernetes/lease.js.map +1 -0
  44. package/dist/backends/kubernetes/objects.d.ts +702 -0
  45. package/dist/backends/kubernetes/objects.d.ts.map +1 -0
  46. package/dist/backends/kubernetes/objects.js +518 -0
  47. package/dist/backends/kubernetes/objects.js.map +1 -0
  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/privilege-probe.d.ts +136 -0
  53. package/dist/backends/kubernetes/privilege-probe.d.ts.map +1 -0
  54. package/dist/backends/kubernetes/privilege-probe.js +185 -0
  55. package/dist/backends/kubernetes/privilege-probe.js.map +1 -0
  56. package/dist/backends/kubernetes/rbac.d.ts +153 -0
  57. package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
  58. package/dist/backends/kubernetes/rbac.js +177 -0
  59. package/dist/backends/kubernetes/rbac.js.map +1 -0
  60. package/dist/backends/kubernetes/sandbox.d.ts +190 -0
  61. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -0
  62. package/dist/backends/kubernetes/sandbox.js +433 -0
  63. package/dist/backends/kubernetes/sandbox.js.map +1 -0
  64. package/dist/backends/kubernetes/transport.d.ts +1048 -0
  65. package/dist/backends/kubernetes/transport.d.ts.map +1 -0
  66. package/dist/backends/kubernetes/transport.js +2093 -0
  67. package/dist/backends/kubernetes/transport.js.map +1 -0
  68. package/dist/backends/kubernetes/workspace.d.ts +1512 -0
  69. package/dist/backends/kubernetes/workspace.d.ts.map +1 -0
  70. package/dist/backends/kubernetes/workspace.js +3703 -0
  71. package/dist/backends/kubernetes/workspace.js.map +1 -0
  72. package/dist/backends/remote-execution-controller.d.ts +14 -0
  73. package/dist/backends/remote-execution-controller.d.ts.map +1 -1
  74. package/dist/backends/remote-execution-controller.js.map +1 -1
  75. package/dist/index.d.ts +350 -2
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +344 -34
  78. package/dist/index.js.map +1 -1
  79. package/dist/testing/sandbox-conformance.d.ts +227 -0
  80. package/dist/testing/sandbox-conformance.d.ts.map +1 -0
  81. package/dist/testing/sandbox-conformance.js +896 -0
  82. package/dist/testing/sandbox-conformance.js.map +1 -0
  83. package/package.json +5 -4
  84. package/src/backends/aci-standby-pool/index.ts +16 -1
  85. package/src/backends/docker/index.ts +22 -1
  86. package/src/backends/firecracker/index.ts +14 -2
  87. package/src/backends/firecracker/protocol.ts +541 -6
  88. package/src/backends/firecracker/transport.ts +1687 -64
  89. package/src/backends/kubernetes/egress-policy.ts +3448 -0
  90. package/src/backends/kubernetes/identity.ts +261 -0
  91. package/src/backends/kubernetes/index.ts +2670 -0
  92. package/src/backends/kubernetes/ingress-policy.ts +1344 -0
  93. package/src/backends/kubernetes/k8s-client.ts +742 -0
  94. package/src/backends/kubernetes/lease.ts +254 -0
  95. package/src/backends/kubernetes/objects.ts +983 -0
  96. package/src/backends/kubernetes/per-sandbox-policy.ts +542 -0
  97. package/src/backends/kubernetes/privilege-probe.ts +261 -0
  98. package/src/backends/kubernetes/rbac.ts +192 -0
  99. package/src/backends/kubernetes/sandbox.ts +593 -0
  100. package/src/backends/kubernetes/transport.ts +2895 -0
  101. package/src/backends/kubernetes/workspace.ts +5640 -0
  102. package/src/backends/remote-execution-controller.ts +14 -0
  103. package/src/index.ts +838 -35
  104. 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
+ }