rightsize 0.7.9 → 0.7.12
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/README.md +6 -4
- package/dist/backend-docker/backend.d.ts +29 -3
- package/dist/backend-docker/backend.d.ts.map +1 -1
- package/dist/backend-docker/backend.js +30 -3
- package/dist/backend-docker/backend.js.map +1 -1
- package/dist/backend-msb/backend.d.ts +657 -83
- package/dist/backend-msb/backend.d.ts.map +1 -1
- package/dist/backend-msb/backend.js +1319 -149
- package/dist/backend-msb/backend.js.map +1 -1
- package/dist/backend-msb/commands.d.ts +162 -16
- package/dist/backend-msb/commands.d.ts.map +1 -1
- package/dist/backend-msb/commands.js +228 -32
- package/dist/backend-msb/commands.js.map +1 -1
- package/dist/backend-msb/network-links.d.ts +86 -1
- package/dist/backend-msb/network-links.d.ts.map +1 -1
- package/dist/backend-msb/network-links.js +119 -4
- package/dist/backend-msb/network-links.js.map +1 -1
- package/dist/backend-msb/provisioner.d.ts +1 -1
- package/dist/backend-msb/provisioner.d.ts.map +1 -1
- package/dist/backend-msb/provisioner.js +1 -1
- package/dist/backend-msb/provisioner.js.map +1 -1
- package/dist/backend-msb/restore-access-denied.d.ts +50 -0
- package/dist/backend-msb/restore-access-denied.d.ts.map +1 -0
- package/dist/backend-msb/restore-access-denied.js +52 -0
- package/dist/backend-msb/restore-access-denied.js.map +1 -0
- package/dist/backend-msb/restore-broker.d.ts +181 -0
- package/dist/backend-msb/restore-broker.d.ts.map +1 -0
- package/dist/backend-msb/restore-broker.js +264 -0
- package/dist/backend-msb/restore-broker.js.map +1 -0
- package/dist/backend-msb/sandbox-already-exists.d.ts +32 -0
- package/dist/backend-msb/sandbox-already-exists.d.ts.map +1 -0
- package/dist/backend-msb/sandbox-already-exists.js +35 -0
- package/dist/backend-msb/sandbox-already-exists.js.map +1 -0
- package/dist/backend-msb/snapshot-create.d.ts +22 -0
- package/dist/backend-msb/snapshot-create.d.ts.map +1 -0
- package/dist/backend-msb/snapshot-create.js +33 -0
- package/dist/backend-msb/snapshot-create.js.map +1 -0
- package/dist/backend-msb/snapshot-import.d.ts +30 -14
- package/dist/backend-msb/snapshot-import.d.ts.map +1 -1
- package/dist/backend-msb/snapshot-import.js +35 -17
- package/dist/backend-msb/snapshot-import.js.map +1 -1
- package/dist/backend-msb/snapshot-rm.d.ts +28 -0
- package/dist/backend-msb/snapshot-rm.d.ts.map +1 -0
- package/dist/backend-msb/snapshot-rm.js +30 -0
- package/dist/backend-msb/snapshot-rm.js.map +1 -0
- package/dist/core/backend.d.ts +68 -23
- package/dist/core/backend.d.ts.map +1 -1
- package/dist/core/checkpoint/api.d.ts +2 -2
- package/dist/core/checkpoint/api.js +2 -2
- package/dist/core/checkpoint/registry.d.ts +46 -0
- package/dist/core/checkpoint/registry.d.ts.map +1 -1
- package/dist/core/checkpoint/registry.js +44 -3
- package/dist/core/checkpoint/registry.js.map +1 -1
- package/dist/core/errors.d.ts +49 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +61 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/free-ports.d.ts +18 -0
- package/dist/core/free-ports.d.ts.map +1 -1
- package/dist/core/free-ports.js +61 -0
- package/dist/core/free-ports.js.map +1 -1
- package/dist/core/generic-container.d.ts +101 -12
- package/dist/core/generic-container.d.ts.map +1 -1
- package/dist/core/generic-container.js +399 -71
- package/dist/core/generic-container.js.map +1 -1
- package/dist/core/model.d.ts +40 -3
- package/dist/core/model.d.ts.map +1 -1
- package/dist/core/network.d.ts +6 -2
- package/dist/core/network.d.ts.map +1 -1
- package/dist/core/network.js +4 -1
- package/dist/core/network.js.map +1 -1
- package/dist/core/reuse/hash.d.ts +12 -0
- package/dist/core/reuse/hash.d.ts.map +1 -1
- package/dist/core/reuse/hash.js +1 -0
- package/dist/core/reuse/hash.js.map +1 -1
- package/dist/core/reuse/registry.d.ts +10 -1
- package/dist/core/reuse/registry.d.ts.map +1 -1
- package/dist/core/reuse/registry.js +15 -1
- package/dist/core/reuse/registry.js.map +1 -1
- package/dist/core/sandbox-name.d.ts +3 -0
- package/dist/core/sandbox-name.d.ts.map +1 -0
- package/dist/core/sandbox-name.js +19 -0
- package/dist/core/sandbox-name.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/modules/cassandra.d.ts +12 -12
- package/dist/modules/cassandra.js +13 -13
- package/dist/modules/cassandra.js.map +1 -1
- package/dist/modules/minio.d.ts +5 -3
- package/dist/modules/minio.d.ts.map +1 -1
- package/dist/modules/minio.js +14 -4
- package/dist/modules/minio.js.map +1 -1
- package/dist/modules/postgres.d.ts +2 -2
- package/dist/modules/postgres.d.ts.map +1 -1
- package/dist/modules/postgres.js +11 -9
- package/dist/modules/postgres.js.map +1 -1
- package/package.json +1 -1
- package/dist/backend-msb/snapshot-list.d.ts +0 -43
- package/dist/backend-msb/snapshot-list.d.ts.map +0 -1
- package/dist/backend-msb/snapshot-list.js +0 -53
- package/dist/backend-msb/snapshot-list.js.map +0 -1
|
@@ -4,11 +4,12 @@ import * as os from "node:os";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { FreePorts } from "./free-ports.js";
|
|
6
6
|
import { RunId } from "./run-id.js";
|
|
7
|
+
import { nextSandboxName } from "./sandbox-name.js";
|
|
7
8
|
import { Network } from "./network.js";
|
|
8
|
-
import { PortBindConflictError, IsolationRequiredError, CheckpointUnsupportedError, CheckpointBackendMismatchError, ReuseFromCheckpointError, RelativeContainerPathError, BackendError, RootDiskConflictError, TmpfsRootExceedsMemoryError, NetworkDisabledConflictError, TmpfsRootCheckpointError, } from "./errors.js";
|
|
9
|
+
import { PortBindConflictError, IsolationRequiredError, CheckpointUnsupportedError, CheckpointBackendMismatchError, ReuseFromCheckpointError, CheckpointRestoreEnvOverrideError, RelativeContainerPathError, BackendError, RootDiskConflictError, TmpfsRootExceedsMemoryError, NetworkDisabledConflictError, TmpfsRootCheckpointError, } from "./errors.js";
|
|
9
10
|
import { requireValidCheckpointName } from "./checkpoint/name.js";
|
|
10
11
|
import { checkpointRef } from "./checkpoint/ref.js";
|
|
11
|
-
import { toCheckpointRegistrySpec, writeCheckpointRegistryAtomic } from "./checkpoint/registry.js";
|
|
12
|
+
import { toCheckpointRegistrySpec, writeCheckpointRegistryAtomic, readCheckpointRegistry } from "./checkpoint/registry.js";
|
|
12
13
|
import { Wait } from "./wait.js";
|
|
13
14
|
import { registerSyncCleanup, unregisterSyncCleanup } from "./cleanup.js";
|
|
14
15
|
import { Backends } from "./backends.js";
|
|
@@ -24,12 +25,29 @@ function requireAbsoluteContainerPath(containerPath) {
|
|
|
24
25
|
throw new RelativeContainerPathError(containerPath);
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Order-insensitive equality for env pairs — `start()`'s only use is telling
|
|
30
|
+
* "the checkpoint's own captured env, re-applied unchanged" from "the caller
|
|
31
|
+
* actually changed it" (see `CheckpointRestoreEnvOverrideError`). Compared as
|
|
32
|
+
* key/value sets rather than positionally: `withEnv()` re-setting an
|
|
33
|
+
* existing key moves it to the end of `envPairs` (last-write-wins,
|
|
34
|
+
* insertion-ordered — see `withEnv()`'s own doc), which must not by itself
|
|
35
|
+
* read as a change.
|
|
36
|
+
*/
|
|
37
|
+
function envPairsEqual(a, b) {
|
|
38
|
+
const aMap = new Map(a);
|
|
39
|
+
const bMap = new Map(b);
|
|
40
|
+
if (aMap.size !== bMap.size) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
for (const [key, value] of aMap) {
|
|
44
|
+
if (bMap.get(key) !== value) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
32
49
|
}
|
|
50
|
+
const MAX_START_ATTEMPTS = 5;
|
|
33
51
|
/** Typed-first, string-fallback classification: walk the cause chain for a typed conflict, else match the daemon's own wording. */
|
|
34
52
|
function isPortBindConflict(err) {
|
|
35
53
|
let current = err;
|
|
@@ -75,6 +93,10 @@ function registryRecordToPorts(record) {
|
|
|
75
93
|
}
|
|
76
94
|
return ports;
|
|
77
95
|
}
|
|
96
|
+
/** `ContainerSpec.hostUdpEgressPorts`'s own computation: the distinct, ascending-sorted target host ports of `links`' UDP-protocol entries — shared by every spec-construction site a networked boot can reach. */
|
|
97
|
+
function hostUdpEgressPortsFor(links) {
|
|
98
|
+
return [...new Set(links.filter((link) => link.protocol === "udp").map((link) => link.targetHostPort))].sort((a, b) => a - b);
|
|
99
|
+
}
|
|
78
100
|
/**
|
|
79
101
|
* The builder, launcher, and lifecycle guard for a single container — the
|
|
80
102
|
* one class every module (`RedisContainer`, `PostgresContainer`, …)
|
|
@@ -97,6 +119,8 @@ export class GenericContainer {
|
|
|
97
119
|
image;
|
|
98
120
|
envPairs = [];
|
|
99
121
|
exposedPorts = [];
|
|
122
|
+
/** UDP counterpart of `exposedPorts` (`withExposedUdpPorts`) — a separate field, never merged with the TCP list; see `PortBinding`'s own doc on why the same guest port can appear in both. */
|
|
123
|
+
exposedUdpPorts = [];
|
|
100
124
|
command;
|
|
101
125
|
network;
|
|
102
126
|
aliasNames = [];
|
|
@@ -111,9 +135,20 @@ export class GenericContainer {
|
|
|
111
135
|
requireIsolationRequested = false;
|
|
112
136
|
checkpointRef;
|
|
113
137
|
checkpointSourceBackend;
|
|
138
|
+
/**
|
|
139
|
+
* `cp.spec.env` exactly as captured, set once by `fromCheckpoint()` and
|
|
140
|
+
* never touched again — kept separate from the mutable `envPairs` so
|
|
141
|
+
* `start()` can tell "the caller re-applied the checkpoint's own env
|
|
142
|
+
* unchanged" from "the caller actually overrode or extended it" (see
|
|
143
|
+
* `CheckpointRestoreEnvOverrideError`). `undefined` for every container
|
|
144
|
+
* not built via `fromCheckpoint()`.
|
|
145
|
+
*/
|
|
146
|
+
checkpointCapturedEnv;
|
|
114
147
|
handle;
|
|
115
148
|
backend;
|
|
116
149
|
mappedPorts = new Map();
|
|
150
|
+
/** UDP counterpart of `mappedPorts` — a SEPARATE per-protocol store, never a shared bare-int-keyed map (see `getMappedUdpPort`). */
|
|
151
|
+
mappedUdpPorts = new Map();
|
|
117
152
|
running = false;
|
|
118
153
|
installedNetworkLinks = [];
|
|
119
154
|
/** Builds against `image` (e.g. `"redis:8.6-alpine"`); no I/O happens until `start()`. */
|
|
@@ -136,7 +171,14 @@ export class GenericContainer {
|
|
|
136
171
|
* captures. `start()` throws `CheckpointBackendMismatchError` before any
|
|
137
172
|
* backend call if the active backend isn't the one that created `cp`, and
|
|
138
173
|
* `ReuseFromCheckpointError` if this container is also marked
|
|
139
|
-
* `withReuse()` — reuse's identity hash never covers a checkpoint ref.
|
|
174
|
+
* `withReuse()` — reuse's identity hash never covers a checkpoint ref. On
|
|
175
|
+
* msb specifically, `start()` also throws `CheckpointRestoreEnvOverrideError`
|
|
176
|
+
* before any backend call if a further `withEnv()` call actually changes
|
|
177
|
+
* the env beyond what `cp.spec.env` captured — msb's restore command has
|
|
178
|
+
* no way to inject env at boot time (see that error's own doc); re-calling
|
|
179
|
+
* `withEnv()` with the checkpoint's own captured values, unchanged, never
|
|
180
|
+
* throws. docker is unaffected — an overridden/extended env reaches a
|
|
181
|
+
* docker restore normally.
|
|
140
182
|
* Once started, a restored container is ordinary in every other respect:
|
|
141
183
|
* fresh host ports, normal reaping-ledger tracking, normal stop.
|
|
142
184
|
*/
|
|
@@ -144,14 +186,26 @@ export class GenericContainer {
|
|
|
144
186
|
const container = new GenericContainer(cp.ref);
|
|
145
187
|
container.checkpointRef = cp.ref;
|
|
146
188
|
container.checkpointSourceBackend = cp.backend;
|
|
189
|
+
container.checkpointCapturedEnv = cp.spec.env;
|
|
147
190
|
for (const [key, value] of cp.spec.env) {
|
|
148
191
|
container.withEnv(key, value);
|
|
149
192
|
}
|
|
150
193
|
if (cp.spec.command !== undefined) {
|
|
151
194
|
container.withCommand(...cp.spec.command);
|
|
152
195
|
}
|
|
153
|
-
|
|
154
|
-
|
|
196
|
+
// Split by protocol into the right builder field — cp.spec.ports mixes
|
|
197
|
+
// both transports (see PortBinding's own doc on why they're tagged
|
|
198
|
+
// rather than kept in separate lists at the ContainerSpec level), but
|
|
199
|
+
// the builder itself keeps two independent fields, exactly the way a
|
|
200
|
+
// fresh (non-restored) container's own withExposedPorts()/
|
|
201
|
+
// withExposedUdpPorts() calls would populate them.
|
|
202
|
+
const tcpGuestPorts = cp.spec.ports.filter((p) => p.protocol === "tcp").map((p) => p.guestPort);
|
|
203
|
+
const udpGuestPorts = cp.spec.ports.filter((p) => p.protocol === "udp").map((p) => p.guestPort);
|
|
204
|
+
if (tcpGuestPorts.length > 0) {
|
|
205
|
+
container.withExposedPorts(...tcpGuestPorts);
|
|
206
|
+
}
|
|
207
|
+
if (udpGuestPorts.length > 0) {
|
|
208
|
+
container.withExposedUdpPorts(...udpGuestPorts);
|
|
155
209
|
}
|
|
156
210
|
if (cp.spec.memoryLimitMb !== undefined) {
|
|
157
211
|
container.withMemoryLimit(cp.spec.memoryLimitMb);
|
|
@@ -167,11 +221,34 @@ export class GenericContainer {
|
|
|
167
221
|
this.envPairs.push([key, value]);
|
|
168
222
|
return this;
|
|
169
223
|
}
|
|
170
|
-
/** Publishes these guest ports to pre-allocated host ports (see `FreePorts`); read back with `getMappedPort`. */
|
|
224
|
+
/** Publishes these guest ports to pre-allocated host ports (see `FreePorts`); read back with `getMappedPort`. TCP only — see `withExposedUdpPorts` for UDP. */
|
|
171
225
|
withExposedPorts(...ports) {
|
|
172
226
|
this.exposedPorts.push(...ports);
|
|
173
227
|
return this;
|
|
174
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Publishes these guest ports over UDP to pre-allocated host ports (see
|
|
231
|
+
* `FreePorts.allocateUdp`); read back with `getMappedUdpPort`. A separate
|
|
232
|
+
* list from `withExposedPorts` — the same numeric guest port may be
|
|
233
|
+
* exposed on both protocols (DNS's 53, say), and each gets its own
|
|
234
|
+
* independent host-port mapping.
|
|
235
|
+
*
|
|
236
|
+
* UDP exposure is deliberately invisible to the default wait strategies:
|
|
237
|
+
* `Wait.forListeningPort()`/`Wait.forHttp()` only ever probe
|
|
238
|
+
* `withExposedPorts`' TCP guest ports, so a container exposing ONLY UDP
|
|
239
|
+
* ports is vacuously ready under the default wait — prefer
|
|
240
|
+
* `Wait.forLogMessage(...)` for a UDP-only service.
|
|
241
|
+
*
|
|
242
|
+
* A UDP-exposed container can be a link target on a `Network` on both
|
|
243
|
+
* backends. On microsandbox the consumer gets an in-guest forwarder per UDP
|
|
244
|
+
* link, which needs a busybox-style `nc` (with `-u`/`-e`) and `timeout` in
|
|
245
|
+
* the consumer image; a datagram over 1472 bytes of payload breaks the
|
|
246
|
+
* receiving sandbox's inbound networking there (see the networking guide).
|
|
247
|
+
*/
|
|
248
|
+
withExposedUdpPorts(...ports) {
|
|
249
|
+
this.exposedUdpPorts.push(...ports);
|
|
250
|
+
return this;
|
|
251
|
+
}
|
|
175
252
|
/** Overrides the image's default ENTRYPOINT/CMD. Omit this call entirely to run the image unmodified. */
|
|
176
253
|
withCommand(...cmd) {
|
|
177
254
|
this.command = cmd;
|
|
@@ -310,18 +387,14 @@ export class GenericContainer {
|
|
|
310
387
|
resolveBackend() {
|
|
311
388
|
return this.backendOverride ?? Backends.active();
|
|
312
389
|
}
|
|
313
|
-
|
|
390
|
+
/** `hostUdpEgressPorts` is precomputed by `start()` from the SAME `linksForNewMember()` call `installNetworkLinks` is later given — never recomputed here, so the argv a networked boot builds from and the links actually installed after it always agree. */
|
|
391
|
+
buildSpec(name, ports, hostUdpEgressPorts) {
|
|
314
392
|
const spec = {
|
|
315
393
|
name,
|
|
316
394
|
image: this.image,
|
|
317
395
|
env: this.envPairs.map(([k, v]) => [k, v]),
|
|
318
396
|
command: this.command,
|
|
319
|
-
ports: this.
|
|
320
|
-
hostPort: ports.get(guestPort) ?? (() => {
|
|
321
|
-
throw new Error(`no allocated host port for guest port ${guestPort}`);
|
|
322
|
-
})(),
|
|
323
|
-
guestPort,
|
|
324
|
-
})),
|
|
397
|
+
ports: this.buildPortBindings(ports),
|
|
325
398
|
mounts: this.mounts,
|
|
326
399
|
networkId: this.network?.id,
|
|
327
400
|
aliases: this.aliasNames,
|
|
@@ -336,9 +409,10 @@ export class GenericContainer {
|
|
|
336
409
|
diskLimitMb: this.diskLimitMb,
|
|
337
410
|
tmpfsRootMb: this.tmpfsRootMb,
|
|
338
411
|
networkDisabled: this.networkDisabled,
|
|
412
|
+
hostUdpEgressPorts,
|
|
339
413
|
};
|
|
340
414
|
return GenericContainer.validateSpecConflicts(this.customizeSpec(spec, (guest) => {
|
|
341
|
-
const p = ports.get(guest);
|
|
415
|
+
const p = ports.tcp.get(guest);
|
|
342
416
|
if (p === undefined) {
|
|
343
417
|
throw new Error(`no allocated host port for guest port ${guest}`);
|
|
344
418
|
}
|
|
@@ -352,12 +426,7 @@ export class GenericContainer {
|
|
|
352
426
|
image: this.image,
|
|
353
427
|
env: this.envPairs.map(([k, v]) => [k, v]),
|
|
354
428
|
command: this.command,
|
|
355
|
-
ports: this.
|
|
356
|
-
hostPort: ports.get(guestPort) ?? (() => {
|
|
357
|
-
throw new Error(`no allocated host port for guest port ${guestPort}`);
|
|
358
|
-
})(),
|
|
359
|
-
guestPort,
|
|
360
|
-
})),
|
|
429
|
+
ports: this.buildPortBindings(ports),
|
|
361
430
|
mounts: this.mounts,
|
|
362
431
|
networkId: undefined,
|
|
363
432
|
aliases: this.aliasNames,
|
|
@@ -371,9 +440,13 @@ export class GenericContainer {
|
|
|
371
440
|
diskLimitMb: this.diskLimitMb,
|
|
372
441
|
tmpfsRootMb: this.tmpfsRootMb,
|
|
373
442
|
networkDisabled: this.networkDisabled,
|
|
443
|
+
// A reuse container never joins a Network (ReuseWithNetworkError,
|
|
444
|
+
// checked before this is ever reached) — no links, ever, to derive a
|
|
445
|
+
// host-UDP-egress list from.
|
|
446
|
+
hostUdpEgressPorts: [],
|
|
374
447
|
};
|
|
375
448
|
return GenericContainer.validateSpecConflicts(this.customizeSpec(spec, (guest) => {
|
|
376
|
-
const p = ports.get(guest);
|
|
449
|
+
const p = ports.tcp.get(guest);
|
|
377
450
|
if (p === undefined) {
|
|
378
451
|
throw new Error(`no allocated host port for guest port ${guest}`);
|
|
379
452
|
}
|
|
@@ -406,6 +479,7 @@ export class GenericContainer {
|
|
|
406
479
|
env: this.envPairs.map(([k, v]) => [k, v]),
|
|
407
480
|
command: this.command,
|
|
408
481
|
exposedPorts: this.exposedPorts,
|
|
482
|
+
exposedUdpPorts: this.exposedUdpPorts,
|
|
409
483
|
memoryLimitMb: this.memoryLimitMb,
|
|
410
484
|
copies: this.mounts.map((m) => ({ guestPath: m.guestPath, hostPath: m.hostPath })),
|
|
411
485
|
diskLimitMb: this.diskLimitMb,
|
|
@@ -414,16 +488,44 @@ export class GenericContainer {
|
|
|
414
488
|
});
|
|
415
489
|
}
|
|
416
490
|
async allocatePorts() {
|
|
417
|
-
const
|
|
491
|
+
const tcp = new Map();
|
|
418
492
|
for (const guestPort of this.exposedPorts) {
|
|
419
|
-
|
|
493
|
+
tcp.set(guestPort, await FreePorts.allocate());
|
|
420
494
|
}
|
|
421
|
-
|
|
495
|
+
const udp = new Map();
|
|
496
|
+
for (const guestPort of this.exposedUdpPorts) {
|
|
497
|
+
udp.set(guestPort, await FreePorts.allocateUdp());
|
|
498
|
+
}
|
|
499
|
+
return { tcp, udp };
|
|
422
500
|
}
|
|
423
501
|
releasePorts(ports) {
|
|
424
|
-
for (const hostPort of ports.values()) {
|
|
502
|
+
for (const hostPort of ports.tcp.values()) {
|
|
425
503
|
FreePorts.release(hostPort);
|
|
426
504
|
}
|
|
505
|
+
for (const hostPort of ports.udp.values()) {
|
|
506
|
+
FreePorts.releaseUdp(hostPort);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** Builds the `ContainerSpec.ports` array from this builder's TCP and UDP exposed-port lists plus their allocated host ports — shared by `buildSpec` and `buildReuseSpec`. */
|
|
510
|
+
buildPortBindings(ports) {
|
|
511
|
+
return [
|
|
512
|
+
...this.exposedPorts.map((guestPort) => ({
|
|
513
|
+
hostPort: ports.tcp.get(guestPort) ??
|
|
514
|
+
(() => {
|
|
515
|
+
throw new Error(`no allocated host port for guest port ${guestPort}`);
|
|
516
|
+
})(),
|
|
517
|
+
guestPort,
|
|
518
|
+
protocol: "tcp",
|
|
519
|
+
})),
|
|
520
|
+
...this.exposedUdpPorts.map((guestPort) => ({
|
|
521
|
+
hostPort: ports.udp.get(guestPort) ??
|
|
522
|
+
(() => {
|
|
523
|
+
throw new Error(`no allocated host port for guest port ${guestPort}/udp`);
|
|
524
|
+
})(),
|
|
525
|
+
guestPort,
|
|
526
|
+
protocol: "udp",
|
|
527
|
+
})),
|
|
528
|
+
];
|
|
427
529
|
}
|
|
428
530
|
/**
|
|
429
531
|
* Fails fast — before any backend call — on the combinations
|
|
@@ -466,6 +568,20 @@ export class GenericContainer {
|
|
|
466
568
|
if (this.checkpointSourceBackend !== undefined && this.checkpointSourceBackend !== backend.name) {
|
|
467
569
|
throw new CheckpointBackendMismatchError(this.checkpointSourceBackend, backend.name);
|
|
468
570
|
}
|
|
571
|
+
// Also before any backend work: msb's restore command has no -e/--env
|
|
572
|
+
// flag at all (see CheckpointRestoreEnvOverrideError's own doc) — an env
|
|
573
|
+
// the caller has genuinely changed beyond the checkpoint's own captured
|
|
574
|
+
// values can never reach a restored msb sandbox, so this refuses rather
|
|
575
|
+
// than silently booting it without the env the caller asked for. Only
|
|
576
|
+
// msb is checked: docker's restore is an ordinary create/run against the
|
|
577
|
+
// committed image, so an overridden env reaches it normally. Re-applying
|
|
578
|
+
// the checkpoint's own captured env unchanged never throws — only a call
|
|
579
|
+
// that actually changes what would be sent does.
|
|
580
|
+
if (this.checkpointRef !== undefined &&
|
|
581
|
+
backend.name === "microsandbox" &&
|
|
582
|
+
!envPairsEqual(this.checkpointCapturedEnv ?? [], this.envPairs)) {
|
|
583
|
+
throw new CheckpointRestoreEnvOverrideError(backend.name);
|
|
584
|
+
}
|
|
469
585
|
// Only the env-resolved path (never an explicit withBackend() override)
|
|
470
586
|
// drives the reaper: writes this process's run record, sweeps dead runs
|
|
471
587
|
// on first use, and spawns the watchdog. Skipping it for overrides is
|
|
@@ -504,6 +620,15 @@ export class GenericContainer {
|
|
|
504
620
|
await backend.ensureNetwork(this.network.id);
|
|
505
621
|
await trackNetwork(this.network.id);
|
|
506
622
|
}
|
|
623
|
+
// Computed once, before create() — never recomputed inside the retry
|
|
624
|
+
// loop below or ahead of the installNetworkLinks call after it: a
|
|
625
|
+
// backend that routes links through the host (msb) needs this
|
|
626
|
+
// container's own UDP-egress policy baked into the SAME argv that boots
|
|
627
|
+
// it, and the links actually installed once it's up must be the exact
|
|
628
|
+
// list that policy was built from, not a freshly recomputed one that
|
|
629
|
+
// could in principle disagree with it.
|
|
630
|
+
const links = this.network?.linksForNewMember() ?? [];
|
|
631
|
+
const hostUdpEgressPorts = hostUdpEgressPortsFor(links);
|
|
507
632
|
let handle;
|
|
508
633
|
let allocatedPorts;
|
|
509
634
|
let lastConflict;
|
|
@@ -511,13 +636,16 @@ export class GenericContainer {
|
|
|
511
636
|
// set) until the retry loop exits: releasing them immediately would let the
|
|
512
637
|
// next attempt legally re-pick the very port that just conflicted — the OS
|
|
513
638
|
// frequently reissues a just-freed ephemeral port — wasting an attempt on a
|
|
514
|
-
// proven-contended port.
|
|
515
|
-
|
|
639
|
+
// proven-contended port. Tracked per protocol so the finally block below
|
|
640
|
+
// releases each back into the RIGHT pool (TCP vs UDP have independent OS
|
|
641
|
+
// port tables — see FreePorts.allocateUdp's own doc).
|
|
642
|
+
const conflictedTcpPorts = [];
|
|
643
|
+
const conflictedUdpPorts = [];
|
|
516
644
|
try {
|
|
517
645
|
for (let attempt = 0; attempt < MAX_START_ATTEMPTS; attempt++) {
|
|
518
646
|
const ports = await this.allocatePorts();
|
|
519
|
-
const name =
|
|
520
|
-
const spec = this.buildSpec(name, ports);
|
|
647
|
+
const name = nextSandboxName();
|
|
648
|
+
const spec = this.buildSpec(name, ports, hostUdpEgressPorts);
|
|
521
649
|
// Appended BEFORE create() — the ledger's `.sandboxes` file is always
|
|
522
650
|
// a superset of this run's live sandboxes, never a subset. `keepAlive`
|
|
523
651
|
// containers are never listed at all, so a sweep can never reach one
|
|
@@ -544,8 +672,9 @@ export class GenericContainer {
|
|
|
544
672
|
await untrackSandbox(name);
|
|
545
673
|
}
|
|
546
674
|
if (isPortBindConflict(err)) {
|
|
547
|
-
// Quarantined, not released: see
|
|
548
|
-
|
|
675
|
+
// Quarantined, not released: see conflictedTcpPorts/conflictedUdpPorts above.
|
|
676
|
+
conflictedTcpPorts.push(...ports.tcp.values());
|
|
677
|
+
conflictedUdpPorts.push(...ports.udp.values());
|
|
549
678
|
lastConflict = err;
|
|
550
679
|
continue;
|
|
551
680
|
}
|
|
@@ -555,9 +684,12 @@ export class GenericContainer {
|
|
|
555
684
|
}
|
|
556
685
|
}
|
|
557
686
|
finally {
|
|
558
|
-
for (const hostPort of
|
|
687
|
+
for (const hostPort of conflictedTcpPorts) {
|
|
559
688
|
FreePorts.release(hostPort);
|
|
560
689
|
}
|
|
690
|
+
for (const hostPort of conflictedUdpPorts) {
|
|
691
|
+
FreePorts.releaseUdp(hostPort);
|
|
692
|
+
}
|
|
561
693
|
}
|
|
562
694
|
if (handle === undefined || allocatedPorts === undefined) {
|
|
563
695
|
throw new Error(`Failed to start '${this.image}' after ${MAX_START_ATTEMPTS} attempts: every attempt hit a host port already in use by another process.`, { cause: lastConflict });
|
|
@@ -566,7 +698,8 @@ export class GenericContainer {
|
|
|
566
698
|
// even though readiness (below) hasn't been confirmed yet.
|
|
567
699
|
this.handle = handle;
|
|
568
700
|
this.backend = backend;
|
|
569
|
-
this.mappedPorts = allocatedPorts;
|
|
701
|
+
this.mappedPorts = allocatedPorts.tcp;
|
|
702
|
+
this.mappedUdpPorts = allocatedPorts.udp;
|
|
570
703
|
this.running = true;
|
|
571
704
|
// Real synchronous teardown, not a placeholder: if this process dies
|
|
572
705
|
// before stop() ever runs (process.exit / SIGINT / SIGTERM), the exit
|
|
@@ -582,7 +715,10 @@ export class GenericContainer {
|
|
|
582
715
|
registerSyncCleanup(handle, backend, () => backend.cleanupSync(handle.id));
|
|
583
716
|
}
|
|
584
717
|
try {
|
|
585
|
-
|
|
718
|
+
// The SAME links computed above, before create() — never recomputed
|
|
719
|
+
// here (see the comment where they were computed): the policy already
|
|
720
|
+
// baked into this container's own argv must be exactly what gets
|
|
721
|
+
// installed, not a freshly recomputed list.
|
|
586
722
|
await backend.installNetworkLinks(handle, links);
|
|
587
723
|
this.installedNetworkLinks = links;
|
|
588
724
|
// Register AFTER links are computed/installed — a container must
|
|
@@ -614,10 +750,11 @@ export class GenericContainer {
|
|
|
614
750
|
const dir = cacheDir();
|
|
615
751
|
const hash = await this.computeReuseHash();
|
|
616
752
|
const name = reuseName(hash);
|
|
617
|
-
const { handle, mappedPorts } = await this.adoptOrCreate(backend, dir, hash, name, false);
|
|
753
|
+
const { handle, mappedPorts, mappedUdpPorts } = await this.adoptOrCreate(backend, dir, hash, name, false);
|
|
618
754
|
this.handle = handle;
|
|
619
755
|
this.backend = backend;
|
|
620
756
|
this.mappedPorts = mappedPorts;
|
|
757
|
+
this.mappedUdpPorts = mappedUdpPorts;
|
|
621
758
|
this.running = true;
|
|
622
759
|
// Deliberately no registerSyncCleanup and no ledger trackSandbox call —
|
|
623
760
|
// a keepAlive sandbox must outlive this process, which is exactly what
|
|
@@ -676,8 +813,10 @@ export class GenericContainer {
|
|
|
676
813
|
let lastConflict;
|
|
677
814
|
// Same conflicted-port quarantine as the ordinary start path: a port that
|
|
678
815
|
// just failed to bind stays out of the allocator until this retry loop
|
|
679
|
-
// exits, so no later attempt can re-pick it.
|
|
680
|
-
|
|
816
|
+
// exits, so no later attempt can re-pick it. Split by protocol for the
|
|
817
|
+
// same reason start()'s own loop is — see its comment.
|
|
818
|
+
const conflictedTcpPorts = [];
|
|
819
|
+
const conflictedUdpPorts = [];
|
|
681
820
|
try {
|
|
682
821
|
for (let attempt = 0; attempt < MAX_START_ATTEMPTS; attempt++) {
|
|
683
822
|
const ports = await this.allocatePorts();
|
|
@@ -696,8 +835,9 @@ export class GenericContainer {
|
|
|
696
835
|
await swallow(() => backend.remove(createdHandle));
|
|
697
836
|
}
|
|
698
837
|
if (isPortBindConflict(err)) {
|
|
699
|
-
// Quarantined, not released: see
|
|
700
|
-
|
|
838
|
+
// Quarantined, not released: see conflictedTcpPorts/conflictedUdpPorts above.
|
|
839
|
+
conflictedTcpPorts.push(...ports.tcp.values());
|
|
840
|
+
conflictedUdpPorts.push(...ports.udp.values());
|
|
701
841
|
lastConflict = err;
|
|
702
842
|
continue;
|
|
703
843
|
}
|
|
@@ -719,16 +859,19 @@ export class GenericContainer {
|
|
|
719
859
|
}
|
|
720
860
|
}
|
|
721
861
|
finally {
|
|
722
|
-
for (const hostPort of
|
|
862
|
+
for (const hostPort of conflictedTcpPorts) {
|
|
723
863
|
FreePorts.release(hostPort);
|
|
724
864
|
}
|
|
865
|
+
for (const hostPort of conflictedUdpPorts) {
|
|
866
|
+
FreePorts.releaseUdp(hostPort);
|
|
867
|
+
}
|
|
725
868
|
}
|
|
726
869
|
if (handle === undefined || allocatedPorts === undefined) {
|
|
727
870
|
throw new Error(`Failed to start reuse container '${this.image}' after ${MAX_START_ATTEMPTS} attempts: every attempt hit a host port already in use by another process.`, { cause: lastConflict });
|
|
728
871
|
}
|
|
729
872
|
const ports = allocatedPorts;
|
|
730
873
|
try {
|
|
731
|
-
await this.waitStrategy.waitUntilReady(this.waitTargetFor(backend, handle, ports));
|
|
874
|
+
await this.waitStrategy.waitUntilReady(this.waitTargetFor(backend, handle, ports.tcp));
|
|
732
875
|
}
|
|
733
876
|
catch (err) {
|
|
734
877
|
await swallow(() => backend.stop(handle));
|
|
@@ -736,15 +879,19 @@ export class GenericContainer {
|
|
|
736
879
|
this.releasePorts(ports);
|
|
737
880
|
throw err;
|
|
738
881
|
}
|
|
882
|
+
const udpPortsRecord = portsToRegistryRecord(ports.udp);
|
|
739
883
|
const entry = {
|
|
740
884
|
name,
|
|
741
885
|
image: this.image,
|
|
742
|
-
ports: portsToRegistryRecord(ports),
|
|
886
|
+
ports: portsToRegistryRecord(ports.tcp),
|
|
743
887
|
createdIso: new Date().toISOString(),
|
|
744
888
|
backend: backend.name,
|
|
889
|
+
// Omitted when there are none, mirroring every other additive-optional
|
|
890
|
+
// field on this registry's sibling shapes.
|
|
891
|
+
...(Object.keys(udpPortsRecord).length > 0 ? { udpPorts: udpPortsRecord } : {}),
|
|
745
892
|
};
|
|
746
893
|
await writeRegistryAtomic(dir, hash, entry);
|
|
747
|
-
return { handle, mappedPorts: ports };
|
|
894
|
+
return { handle, mappedPorts: ports.tcp, mappedUdpPorts: ports.udp };
|
|
748
895
|
}
|
|
749
896
|
/**
|
|
750
897
|
* Registry hit → verify: confirm the name is actually running (backend
|
|
@@ -757,7 +904,10 @@ export class GenericContainer {
|
|
|
757
904
|
*/
|
|
758
905
|
async tryAdopt(backend, dir, hash, name, entry) {
|
|
759
906
|
const mappedPorts = registryRecordToPorts(entry.ports);
|
|
760
|
-
|
|
907
|
+
// Absent on an entry written before UDP exposure existed — normalizes to
|
|
908
|
+
// no UDP ports, never a corrupt read (see isRegistryEntry in reuse/registry.ts).
|
|
909
|
+
const mappedUdpPorts = registryRecordToPorts(entry.udpPorts ?? {});
|
|
910
|
+
const spec = this.buildReuseSpec(name, { tcp: mappedPorts, udp: mappedUdpPorts });
|
|
761
911
|
const handle = await backend.findRunning(spec).catch(() => undefined);
|
|
762
912
|
if (handle === undefined) {
|
|
763
913
|
await this.cleanupStaleReuse(backend, dir, hash, name);
|
|
@@ -770,7 +920,7 @@ export class GenericContainer {
|
|
|
770
920
|
await this.cleanupStaleReuse(backend, dir, hash, name);
|
|
771
921
|
return undefined;
|
|
772
922
|
}
|
|
773
|
-
return { handle, mappedPorts };
|
|
923
|
+
return { handle, mappedPorts, mappedUdpPorts };
|
|
774
924
|
}
|
|
775
925
|
/** Best-effort: remove whatever's running under `name` (by name — this call never held a handle for it) and delete the registry file. Never throws. */
|
|
776
926
|
async cleanupStaleReuse(backend, dir, hash, name) {
|
|
@@ -795,7 +945,10 @@ export class GenericContainer {
|
|
|
795
945
|
* `findRunning` matches on `spec.name` alone.
|
|
796
946
|
*/
|
|
797
947
|
async removeOrphanedRunningReuse(backend, name) {
|
|
798
|
-
const probePorts =
|
|
948
|
+
const probePorts = {
|
|
949
|
+
tcp: new Map(this.exposedPorts.map((guestPort) => [guestPort, 0])),
|
|
950
|
+
udp: new Map(this.exposedUdpPorts.map((guestPort) => [guestPort, 0])),
|
|
951
|
+
};
|
|
799
952
|
const probeSpec = this.buildReuseSpec(name, probePorts);
|
|
800
953
|
const running = await backend.findRunning(probeSpec).catch(() => undefined);
|
|
801
954
|
if (running !== undefined) {
|
|
@@ -846,14 +999,16 @@ export class GenericContainer {
|
|
|
846
999
|
// simply stay marked issued for the rest of this process's lifetime;
|
|
847
1000
|
// only this instance's own view of them is cleared below.
|
|
848
1001
|
this.mappedPorts = new Map();
|
|
1002
|
+
this.mappedUdpPorts = new Map();
|
|
849
1003
|
return;
|
|
850
1004
|
}
|
|
851
1005
|
await swallow(() => backend.stop(handle));
|
|
852
1006
|
await swallow(() => backend.remove(handle));
|
|
853
1007
|
unregisterSyncCleanup(handle.id);
|
|
854
1008
|
await untrackSandbox(handle.spec.name);
|
|
855
|
-
this.releasePorts(this.mappedPorts);
|
|
1009
|
+
this.releasePorts({ tcp: this.mappedPorts, udp: this.mappedUdpPorts });
|
|
856
1010
|
this.mappedPorts = new Map();
|
|
1011
|
+
this.mappedUdpPorts = new Map();
|
|
857
1012
|
}
|
|
858
1013
|
/** `= stop()`. What `await using c = await new GenericContainer(img).start()` calls at scope exit; never throws. */
|
|
859
1014
|
async [Symbol.asyncDispose]() {
|
|
@@ -871,10 +1026,14 @@ export class GenericContainer {
|
|
|
871
1026
|
get aliases() {
|
|
872
1027
|
return this.aliasNames;
|
|
873
1028
|
}
|
|
874
|
-
/** Every guest port this container published (set via `withExposedPorts`). Part of the `NetworkMember` contract `Network` uses to compute links. */
|
|
1029
|
+
/** Every guest port this container published over TCP (set via `withExposedPorts`). Part of the `NetworkMember` contract `Network` uses to compute links; also what the default wait strategies probe — see `withExposedUdpPorts`' own doc on why UDP ports never appear here. */
|
|
875
1030
|
get exposedGuestPorts() {
|
|
876
1031
|
return this.exposedPorts;
|
|
877
1032
|
}
|
|
1033
|
+
/** Every guest port this container published over UDP (set via `withExposedUdpPorts`) — a separate list from `exposedGuestPorts`, never merged with it. Part of the `NetworkMember` contract `Network` uses to compute links. */
|
|
1034
|
+
get exposedUdpGuestPorts() {
|
|
1035
|
+
return this.exposedUdpPorts;
|
|
1036
|
+
}
|
|
878
1037
|
/**
|
|
879
1038
|
* The host port bound to `guestPort`. Throws distinct messages for the
|
|
880
1039
|
* two ways this can be wrong: not running at all ("call start() first"),
|
|
@@ -895,6 +1054,29 @@ export class GenericContainer {
|
|
|
895
1054
|
mappedPort(guestPort) {
|
|
896
1055
|
return this.getMappedPort(guestPort);
|
|
897
1056
|
}
|
|
1057
|
+
/**
|
|
1058
|
+
* The UDP counterpart of `getMappedPort` — reads from a SEPARATE
|
|
1059
|
+
* per-protocol mapping store (`withExposedUdpPorts`), so exposing the same
|
|
1060
|
+
* numeric guest port on both protocols (DNS's 53, say) never collides:
|
|
1061
|
+
* `getMappedPort(53)` and `getMappedUdpPort(53)` can return two entirely
|
|
1062
|
+
* different host ports. Throws the same two distinct-message shape as
|
|
1063
|
+
* `getMappedPort`: not running at all, versus running but never exposed
|
|
1064
|
+
* via `withExposedUdpPorts`.
|
|
1065
|
+
*/
|
|
1066
|
+
getMappedUdpPort(guestPort) {
|
|
1067
|
+
if (!this.running) {
|
|
1068
|
+
throw new Error(`Container is not running — call start() first.`);
|
|
1069
|
+
}
|
|
1070
|
+
const port = this.mappedUdpPorts.get(guestPort);
|
|
1071
|
+
if (port === undefined) {
|
|
1072
|
+
throw new Error(`Port ${guestPort}/udp is not exposed — call withExposedUdpPorts(${guestPort}).`);
|
|
1073
|
+
}
|
|
1074
|
+
return port;
|
|
1075
|
+
}
|
|
1076
|
+
/** Alias for `getMappedUdpPort`, satisfying the `NetworkMember` shape (which names this method `mappedUdpPort`). */
|
|
1077
|
+
mappedUdpPort(guestPort) {
|
|
1078
|
+
return this.getMappedUdpPort(guestPort);
|
|
1079
|
+
}
|
|
898
1080
|
requireHandle() {
|
|
899
1081
|
if (!this.running || this.handle === undefined || this.backend === undefined) {
|
|
900
1082
|
throw new Error(`Container is not running — call start() first.`);
|
|
@@ -912,21 +1094,48 @@ export class GenericContainer {
|
|
|
912
1094
|
* state error (same shape as `exec`/`logs`) if this container isn't
|
|
913
1095
|
* currently running.
|
|
914
1096
|
*
|
|
915
|
-
* Passing `name` makes this checkpoint NAMED and durable: the ref
|
|
916
|
-
* deterministic (
|
|
917
|
-
* on microsandbox, `rightsize/checkpoint:<name>` on docker — see
|
|
1097
|
+
* Passing `name` makes this checkpoint NAMED and durable: on docker the ref
|
|
1098
|
+
* becomes deterministic (`rightsize/checkpoint:<name>` — see
|
|
918
1099
|
* `checkpointRef` — instead of a random 12-hex suffix), and — only once
|
|
919
1100
|
* the backend call below has actually succeeded — a registry entry is written under
|
|
920
1101
|
* `<cacheDir>/checkpoints/<name>.json` that `Checkpoints.find`/`list`/
|
|
921
1102
|
* `remove` can rediscover later, in this process or a different one. `name`
|
|
922
1103
|
* must match `^[a-z0-9][a-z0-9-]{0,40}$`; an invalid name throws
|
|
923
1104
|
* `InvalidCheckpointNameError` before any backend call. Checkpointing under
|
|
924
|
-
* a name that already has a registry entry REPLACES it:
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
929
|
-
*
|
|
1105
|
+
* a name that already has a registry entry REPLACES it: this best-effort
|
|
1106
|
+
* clears whatever artifact the PRIOR checkpoint under `name` actually left
|
|
1107
|
+
* behind, then overwrites the registry entry with the BACKEND'S OWN
|
|
1108
|
+
* EFFECTIVE ref for the fresh checkpoint — the latest checkpoint under a
|
|
1109
|
+
* name always wins. The prior artifact's real location comes from the
|
|
1110
|
+
* existing registry entry itself (when one exists for a backend matching
|
|
1111
|
+
* the currently active one) rather than a recomputed nominal ref: on
|
|
1112
|
+
* docker the two are always the same deterministic value anyway, but on
|
|
1113
|
+
* microsandbox (since 0.7.1) the effective ref is a content-addressed
|
|
1114
|
+
* artifact path the nominal one never predicts (see
|
|
1115
|
+
* `SandboxBackend.createCheckpoint`'s own doc), so reading it back from the
|
|
1116
|
+
* registry is what makes this reliable there too — the same lookup
|
|
1117
|
+
* `Checkpoints.remove(name)` itself uses.
|
|
1118
|
+
*
|
|
1119
|
+
* The PRIOR entry's real ref is only ever removed once the fresh
|
|
1120
|
+
* checkpoint below has actually been created successfully (and, on a
|
|
1121
|
+
* backend whose checkpoint reboots the workload, once the post-checkpoint
|
|
1122
|
+
* re-wait below has succeeded too) — never before. When the prior ref and
|
|
1123
|
+
* the freshly-minted nominal one are the SAME deterministic value (always
|
|
1124
|
+
* true on docker), removing it earlier would just be removing the nominal
|
|
1125
|
+
* ref that `createCheckpoint` is about to reuse anyway, so this method
|
|
1126
|
+
* best-effort clears the nominal ref up front instead (a harmless no-op on
|
|
1127
|
+
* microsandbox, where a name never predicts a real artifact); when the two
|
|
1128
|
+
* differ (microsandbox's content-addressed case, or the rare coincidence
|
|
1129
|
+
* of the two colliding on any backend), the prior ref is left completely
|
|
1130
|
+
* untouched until the new one is confirmed both real and reachable. That
|
|
1131
|
+
* ordering is what keeps a FAILED same-name re-checkpoint from destroying
|
|
1132
|
+
* an otherwise-good, previously-restorable checkpoint: on any backend, a
|
|
1133
|
+
* `createCheckpoint()` (or post-checkpoint re-wait) that throws leaves both
|
|
1134
|
+
* the prior artifact and its registry entry completely untouched, exactly
|
|
1135
|
+
* as if the failed call had never been attempted. Omitting `name` keeps
|
|
1136
|
+
* the original behavior byte-for-byte: an ephemeral checkpoint with no
|
|
1137
|
+
* registry entry, whose `Checkpoint.ref` is always the backend's own
|
|
1138
|
+
* effective ref.
|
|
930
1139
|
*
|
|
931
1140
|
* On a backend whose `capabilities.checkpointRestartsWorkload` is `true`
|
|
932
1141
|
* (microsandbox: the stop/snapshot/reboot cycle boots a fresh microVM),
|
|
@@ -943,6 +1152,11 @@ export class GenericContainer {
|
|
|
943
1152
|
* BEFORE the wait-strategy re-run — the wait must gate a container that is
|
|
944
1153
|
* fully re-linked, not just booted.
|
|
945
1154
|
*
|
|
1155
|
+
* If that re-wait (or the link re-install before it) fails, this container
|
|
1156
|
+
* is torn down to completion via `stop()` — same as a failed `start()` —
|
|
1157
|
+
* before the rejection propagates, so a revived-but-never-ready workload
|
|
1158
|
+
* never sits around as an orphan.
|
|
1159
|
+
*
|
|
946
1160
|
* Checkpoints are never auto-reaped (a committed image or a disk snapshot
|
|
947
1161
|
* is not a container) — see the checkpoints guide for the manual cleanup
|
|
948
1162
|
* one-liners and the `Checkpoints.remove` cleanup affordance.
|
|
@@ -968,33 +1182,147 @@ export class GenericContainer {
|
|
|
968
1182
|
throw new TmpfsRootCheckpointError();
|
|
969
1183
|
}
|
|
970
1184
|
const ref = checkpointRef(backend.name, name);
|
|
1185
|
+
// Replace semantics: a prior checkpoint under this same name — if any —
|
|
1186
|
+
// is cleared once the new one is confirmed to actually exist (see the
|
|
1187
|
+
// deferred removal after `createCheckpoint`/the post-checkpoint re-wait
|
|
1188
|
+
// below), never before — a failed re-checkpoint must never cost this
|
|
1189
|
+
// container its previously-good, restorable checkpoint. The RELIABLE way
|
|
1190
|
+
// to find where the prior checkpoint actually lives is the registry's
|
|
1191
|
+
// own recorded entry (the same lookup `Checkpoints.remove(name)` uses),
|
|
1192
|
+
// not a freshly-minted nominal ref: on docker the ref is deterministic
|
|
1193
|
+
// from `name`, so the registry entry's `ref` and the nominal one below
|
|
1194
|
+
// are always the same value anyway, but on microsandbox (since 0.7.1)
|
|
1195
|
+
// the EFFECTIVE ref `createCheckpoint` returns is a content-addressed
|
|
1196
|
+
// artifact path the nominal one never determines (see
|
|
1197
|
+
// `SandboxBackend.createCheckpoint`'s own doc) — only the registry's
|
|
1198
|
+
// recorded ref points at the real artifact there. Only consulted when
|
|
1199
|
+
// the entry's own recorded backend matches the CURRENTLY active one — a
|
|
1200
|
+
// foreign-backend ref means nothing to this backend's `removeCheckpoint`,
|
|
1201
|
+
// same gate `find`/`remove` apply to the same mismatch. A missing or
|
|
1202
|
+
// corrupt registry read is treated the same as "no entry" (best-effort:
|
|
1203
|
+
// this must never fail the checkpoint itself), leaving only the
|
|
1204
|
+
// nominal-ref removal below to run.
|
|
1205
|
+
let priorRealRef;
|
|
971
1206
|
if (name !== undefined) {
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1207
|
+
try {
|
|
1208
|
+
const registryRead = await readCheckpointRegistry(cacheDir(), name);
|
|
1209
|
+
if (registryRead.kind === "found" && registryRead.entry.backend === backend.name) {
|
|
1210
|
+
priorRealRef = registryRead.entry.ref;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
catch {
|
|
1214
|
+
// Swallowed deliberately — see the comment above.
|
|
1215
|
+
}
|
|
1216
|
+
// Best-effort clear the freshly-minted NOMINAL ref up front, before
|
|
1217
|
+
// creating the new checkpoint — exactly what this method did before
|
|
1218
|
+
// the registry lookup above existed. On docker this nominal ref IS the
|
|
1219
|
+
// prior checkpoint's real recorded ref (deterministic from `name`), so
|
|
1220
|
+
// this is the only removal that ever runs there, and it must run here:
|
|
1221
|
+
// `createCheckpoint` is about to reuse this exact ref, so there is no
|
|
1222
|
+
// "after" at which removing it wouldn't also remove the checkpoint
|
|
1223
|
+
// just created. On microsandbox this is a no-op against a path that
|
|
1224
|
+
// was never real, same as before this method ever read the registry —
|
|
1225
|
+
// so nothing new is destroyed here before the new checkpoint's success
|
|
1226
|
+
// is confirmed.
|
|
975
1227
|
await swallow(() => backend.removeCheckpoint(ref));
|
|
976
1228
|
}
|
|
977
|
-
|
|
1229
|
+
// The EFFECTIVE ref: on docker this is always `ref` itself; on
|
|
1230
|
+
// microsandbox (since 0.7.1) it is whatever real artifact path
|
|
1231
|
+
// `createCheckpoint` discovered from `msb snapshot create`'s own stdout
|
|
1232
|
+
// — never necessarily `ref`. Everything downstream (the registry entry,
|
|
1233
|
+
// the returned `Checkpoint`) uses this, not the working `ref`.
|
|
1234
|
+
//
|
|
1235
|
+
// Captured BEFORE the call: a backend whose checkpoint reboots the
|
|
1236
|
+
// workload under a fresh identity (msb's `createCheckpoint`, see its own
|
|
1237
|
+
// doc) mutates `handle.id` IN PLACE once that reboot succeeds — the same
|
|
1238
|
+
// carve-out `SandboxBackend.createCheckpoint`'s own interface doc
|
|
1239
|
+
// documents. `registerSyncCleanup` (see start(), above) keyed its exit-path
|
|
1240
|
+
// teardown entry by the OLD id; if the id just changed, that registration
|
|
1241
|
+
// must be re-keyed to the new one too, or the old entry leaks in
|
|
1242
|
+
// `cleanup.ts`'s registry forever (stop() below only ever unregisters by
|
|
1243
|
+
// the CURRENT `handle.id`, which after a rename no longer matches what
|
|
1244
|
+
// was registered) — silently breaking both the exit-path teardown for
|
|
1245
|
+
// this container AND `liveContainers()`/diagnostics, which read that same
|
|
1246
|
+
// registry.
|
|
1247
|
+
const preCheckpointHandleId = handle.id;
|
|
1248
|
+
const effectiveRef = await backend.createCheckpoint(handle, ref);
|
|
1249
|
+
if (handle.id !== preCheckpointHandleId && !handle.spec.keepAlive) {
|
|
1250
|
+
// Mirrors start()'s own keepAlive exclusion: a keepAlive container was
|
|
1251
|
+
// never registered in the first place, so there is nothing to re-key.
|
|
1252
|
+
// The cleanup closure below reads `handle.id` dynamically (see
|
|
1253
|
+
// start()'s own registerSyncCleanup call), so re-registering it under
|
|
1254
|
+
// the new id is automatically correct without capturing anything else.
|
|
1255
|
+
unregisterSyncCleanup(preCheckpointHandleId);
|
|
1256
|
+
registerSyncCleanup(handle, backend, () => backend.cleanupSync(handle.id));
|
|
1257
|
+
}
|
|
978
1258
|
if (backend.capabilities.checkpointRestartsWorkload) {
|
|
979
|
-
|
|
980
|
-
|
|
1259
|
+
// The backend checkpoint call above already succeeded — the artifact
|
|
1260
|
+
// exists — but the reboot it triggered can still leave this container
|
|
1261
|
+
// unusable: a revived workload that never becomes ready. Same shape as
|
|
1262
|
+
// start()'s own post-boot wait (line ~722): a failure here must not
|
|
1263
|
+
// leak the freshly-spawned workload (msb's revived `exec` child sits
|
|
1264
|
+
// in `handle`'s attached slot from the moment the reboot reaches
|
|
1265
|
+
// Running, well before this wait ever resolves). Tear this container
|
|
1266
|
+
// down to completion via the same this.stop() start() uses, so the
|
|
1267
|
+
// handle's attached child — and the sandbox itself — never outlive
|
|
1268
|
+
// this rejection, before rethrowing.
|
|
1269
|
+
try {
|
|
1270
|
+
if (this.installedNetworkLinks.length > 0) {
|
|
1271
|
+
await backend.installNetworkLinks(handle, this.installedNetworkLinks);
|
|
1272
|
+
}
|
|
1273
|
+
await this.waitStrategy.waitUntilReady(this.asWaitTarget());
|
|
1274
|
+
}
|
|
1275
|
+
catch (err) {
|
|
1276
|
+
await this.stop();
|
|
1277
|
+
throw err;
|
|
981
1278
|
}
|
|
982
|
-
await this.waitStrategy.waitUntilReady(this.asWaitTarget());
|
|
983
1279
|
}
|
|
1280
|
+
// The workload argv the backend captured from the guest during the
|
|
1281
|
+
// createCheckpoint() call just above, when `handle.spec.command` was
|
|
1282
|
+
// undefined — see `SandboxBackend.capturedWorkloadCommand`'s own doc.
|
|
1283
|
+
// `undefined` on every backend that never captures one (docker, or msb
|
|
1284
|
+
// when the source container already carried an explicit command).
|
|
1285
|
+
const capturedCommand = backend.capturedWorkloadCommand?.(handle);
|
|
984
1286
|
if (name !== undefined) {
|
|
1287
|
+
// Only reached once the fresh checkpoint above is confirmed both real
|
|
1288
|
+
// (createCheckpoint succeeded) and reachable (the post-checkpoint
|
|
1289
|
+
// re-wait above, when applicable, succeeded too) — a failure at either
|
|
1290
|
+
// point already threw and returned above, before any of this ran. The
|
|
1291
|
+
// PRIOR checkpoint's real recorded ref (see the registry lookup
|
|
1292
|
+
// before createCheckpoint) is best-effort removed here, now that it is
|
|
1293
|
+
// safe to: never when it's the SAME ref as the nominal one already
|
|
1294
|
+
// cleared above (docker — clearing it again here would target the
|
|
1295
|
+
// fresh checkpoint `createCheckpoint` just reused that exact ref for),
|
|
1296
|
+
// and never when it happens to equal the fresh checkpoint's own
|
|
1297
|
+
// EFFECTIVE ref (the rare case of two checkpoints content-addressing
|
|
1298
|
+
// to the very same artifact on microsandbox — removing it would delete
|
|
1299
|
+
// the checkpoint this call just created, not a prior one).
|
|
1300
|
+
if (priorRealRef !== undefined && priorRealRef !== ref && priorRealRef !== effectiveRef) {
|
|
1301
|
+
const realRef = priorRealRef;
|
|
1302
|
+
await swallow(() => backend.removeCheckpoint(realRef));
|
|
1303
|
+
}
|
|
985
1304
|
// Only after the backend checkpoint above has actually succeeded — a
|
|
986
1305
|
// failed createCheckpoint() already threw, so a registry entry is
|
|
987
1306
|
// never written for a checkpoint that doesn't exist.
|
|
988
1307
|
const entry = {
|
|
989
1308
|
name,
|
|
990
|
-
ref,
|
|
1309
|
+
ref: effectiveRef,
|
|
991
1310
|
backend: backend.name,
|
|
992
1311
|
createdIso: new Date().toISOString(),
|
|
993
1312
|
spec: toCheckpointRegistrySpec(handle.spec),
|
|
1313
|
+
...(capturedCommand !== undefined ? { capturedCommand } : {}),
|
|
994
1314
|
};
|
|
995
1315
|
await writeCheckpointRegistryAtomic(cacheDir(), name, entry);
|
|
996
1316
|
}
|
|
997
|
-
|
|
1317
|
+
// `handle.spec.command` already covers an explicit command; only
|
|
1318
|
+
// substitute the captured fallback when the source had none, so a later
|
|
1319
|
+
// `fromCheckpoint(cp).start()` on THIS in-memory `Checkpoint` — unnamed
|
|
1320
|
+
// or named, same process or not — already carries the fully-resolved
|
|
1321
|
+
// workload argv a restore-side backend needs to revive it, the same
|
|
1322
|
+
// merge `fromCheckpointRegistryEntry` applies for a registry-mediated
|
|
1323
|
+
// restore.
|
|
1324
|
+
const effectiveSpec = handle.spec.command !== undefined || capturedCommand === undefined ? handle.spec : { ...handle.spec, command: capturedCommand };
|
|
1325
|
+
return { ref: effectiveRef, backend: backend.name, spec: effectiveSpec };
|
|
998
1326
|
}
|
|
999
1327
|
/**
|
|
1000
1328
|
* Copies a host file or directory into this running container at
|