rightsize 0.1.2 → 0.3.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 (131) hide show
  1. package/README.md +36 -4
  2. package/dist/backend-docker/backend.d.ts +76 -1
  3. package/dist/backend-docker/backend.d.ts.map +1 -1
  4. package/dist/backend-docker/backend.js +156 -13
  5. package/dist/backend-docker/backend.js.map +1 -1
  6. package/dist/backend-docker/cli.d.ts +25 -0
  7. package/dist/backend-docker/cli.d.ts.map +1 -0
  8. package/dist/backend-docker/cli.js +64 -0
  9. package/dist/backend-docker/cli.js.map +1 -0
  10. package/dist/backend-docker/labels.d.ts +27 -5
  11. package/dist/backend-docker/labels.d.ts.map +1 -1
  12. package/dist/backend-docker/labels.js +33 -5
  13. package/dist/backend-docker/labels.js.map +1 -1
  14. package/dist/backend-msb/backend.d.ts +89 -6
  15. package/dist/backend-msb/backend.d.ts.map +1 -1
  16. package/dist/backend-msb/backend.js +179 -14
  17. package/dist/backend-msb/backend.js.map +1 -1
  18. package/dist/backend-msb/commands.d.ts +10 -0
  19. package/dist/backend-msb/commands.d.ts.map +1 -1
  20. package/dist/backend-msb/commands.js +29 -1
  21. package/dist/backend-msb/commands.js.map +1 -1
  22. package/dist/backend-msb/provider.d.ts.map +1 -1
  23. package/dist/backend-msb/provider.js +7 -11
  24. package/dist/backend-msb/provider.js.map +1 -1
  25. package/dist/backend-msb/provisioner.d.ts.map +1 -1
  26. package/dist/backend-msb/provisioner.js +1 -20
  27. package/dist/backend-msb/provisioner.js.map +1 -1
  28. package/dist/backend-msb/snapshot-not-found.d.ts +25 -0
  29. package/dist/backend-msb/snapshot-not-found.d.ts.map +1 -0
  30. package/dist/backend-msb/snapshot-not-found.js +27 -0
  31. package/dist/backend-msb/snapshot-not-found.js.map +1 -0
  32. package/dist/core/backend.d.ts +124 -0
  33. package/dist/core/backend.d.ts.map +1 -1
  34. package/dist/core/backends.d.ts +16 -2
  35. package/dist/core/backends.d.ts.map +1 -1
  36. package/dist/core/backends.js +39 -5
  37. package/dist/core/backends.js.map +1 -1
  38. package/dist/core/cache-dir.d.ts +17 -0
  39. package/dist/core/cache-dir.d.ts.map +1 -0
  40. package/dist/core/cache-dir.js +29 -0
  41. package/dist/core/cache-dir.js.map +1 -0
  42. package/dist/core/checkpoint/api.d.ts +80 -0
  43. package/dist/core/checkpoint/api.d.ts.map +1 -0
  44. package/dist/core/checkpoint/api.js +138 -0
  45. package/dist/core/checkpoint/api.js.map +1 -0
  46. package/dist/core/checkpoint/name.d.ts +12 -0
  47. package/dist/core/checkpoint/name.d.ts.map +1 -0
  48. package/dist/core/checkpoint/name.js +17 -0
  49. package/dist/core/checkpoint/name.js.map +1 -0
  50. package/dist/core/checkpoint/ref.d.ts +12 -0
  51. package/dist/core/checkpoint/ref.d.ts.map +1 -0
  52. package/dist/core/checkpoint/ref.js +16 -0
  53. package/dist/core/checkpoint/ref.js.map +1 -0
  54. package/dist/core/checkpoint/registry.d.ts +107 -0
  55. package/dist/core/checkpoint/registry.d.ts.map +1 -0
  56. package/dist/core/checkpoint/registry.js +180 -0
  57. package/dist/core/checkpoint/registry.js.map +1 -0
  58. package/dist/core/cleanup.d.ts +19 -4
  59. package/dist/core/cleanup.d.ts.map +1 -1
  60. package/dist/core/cleanup.js +14 -27
  61. package/dist/core/cleanup.js.map +1 -1
  62. package/dist/core/diagnostics.d.ts +33 -0
  63. package/dist/core/diagnostics.d.ts.map +1 -0
  64. package/dist/core/diagnostics.js +76 -0
  65. package/dist/core/diagnostics.js.map +1 -0
  66. package/dist/core/errors.d.ts +97 -0
  67. package/dist/core/errors.d.ts.map +1 -1
  68. package/dist/core/errors.js +129 -0
  69. package/dist/core/errors.js.map +1 -1
  70. package/dist/core/generic-container.d.ts +176 -1
  71. package/dist/core/generic-container.d.ts.map +1 -1
  72. package/dist/core/generic-container.js +566 -3
  73. package/dist/core/generic-container.js.map +1 -1
  74. package/dist/core/model.d.ts +39 -0
  75. package/dist/core/model.d.ts.map +1 -1
  76. package/dist/core/network.d.ts.map +1 -1
  77. package/dist/core/network.js +2 -0
  78. package/dist/core/network.js.map +1 -1
  79. package/dist/core/reaper/env.d.ts +14 -0
  80. package/dist/core/reaper/env.d.ts.map +1 -0
  81. package/dist/core/reaper/env.js +22 -0
  82. package/dist/core/reaper/env.js.map +1 -0
  83. package/dist/core/reaper/init.d.ts +39 -0
  84. package/dist/core/reaper/init.d.ts.map +1 -0
  85. package/dist/core/reaper/init.js +140 -0
  86. package/dist/core/reaper/init.js.map +1 -0
  87. package/dist/core/reaper/ledger.d.ts +32 -0
  88. package/dist/core/reaper/ledger.d.ts.map +1 -0
  89. package/dist/core/reaper/ledger.js +160 -0
  90. package/dist/core/reaper/ledger.js.map +1 -0
  91. package/dist/core/reaper/process-liveness.d.ts +39 -0
  92. package/dist/core/reaper/process-liveness.d.ts.map +1 -0
  93. package/dist/core/reaper/process-liveness.js +117 -0
  94. package/dist/core/reaper/process-liveness.js.map +1 -0
  95. package/dist/core/reaper/run-record.d.ts +48 -0
  96. package/dist/core/reaper/run-record.d.ts.map +1 -0
  97. package/dist/core/reaper/run-record.js +58 -0
  98. package/dist/core/reaper/run-record.js.map +1 -0
  99. package/dist/core/reaper/sweep.d.ts +23 -0
  100. package/dist/core/reaper/sweep.d.ts.map +1 -0
  101. package/dist/core/reaper/sweep.js +76 -0
  102. package/dist/core/reaper/sweep.js.map +1 -0
  103. package/dist/core/reaper/watchdog.d.ts +100 -0
  104. package/dist/core/reaper/watchdog.d.ts.map +1 -0
  105. package/dist/core/reaper/watchdog.js +412 -0
  106. package/dist/core/reaper/watchdog.js.map +1 -0
  107. package/dist/core/reuse/env.d.ts +12 -0
  108. package/dist/core/reuse/env.d.ts.map +1 -0
  109. package/dist/core/reuse/env.js +15 -0
  110. package/dist/core/reuse/env.js.map +1 -0
  111. package/dist/core/reuse/hash.d.ts +34 -0
  112. package/dist/core/reuse/hash.d.ts.map +1 -0
  113. package/dist/core/reuse/hash.js +52 -0
  114. package/dist/core/reuse/hash.js.map +1 -0
  115. package/dist/core/reuse/registry.d.ts +50 -0
  116. package/dist/core/reuse/registry.d.ts.map +1 -0
  117. package/dist/core/reuse/registry.js +75 -0
  118. package/dist/core/reuse/registry.js.map +1 -0
  119. package/dist/index.d.ts +8 -4
  120. package/dist/index.d.ts.map +1 -1
  121. package/dist/index.js +5 -2
  122. package/dist/index.js.map +1 -1
  123. package/dist/modules/keycloak.js +1 -1
  124. package/dist/modules/mysql.d.ts.map +1 -1
  125. package/dist/modules/mysql.js +4 -3
  126. package/dist/modules/mysql.js.map +1 -1
  127. package/package.json +1 -1
  128. package/dist/backend-msb/reaper.d.ts +0 -10
  129. package/dist/backend-msb/reaper.d.ts.map +0 -1
  130. package/dist/backend-msb/reaper.js +0 -73
  131. package/dist/backend-msb/reaper.js.map +0 -1
@@ -1,10 +1,29 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
1
5
  import { FreePorts } from "./free-ports.js";
2
6
  import { RunId } from "./run-id.js";
3
7
  import { Network } from "./network.js";
4
- import { PortBindConflictError } from "./errors.js";
8
+ import { PortBindConflictError, IsolationRequiredError, CheckpointUnsupportedError, CheckpointBackendMismatchError, ReuseFromCheckpointError, RelativeContainerPathError, BackendError, } from "./errors.js";
9
+ import { requireValidCheckpointName } from "./checkpoint/name.js";
10
+ import { checkpointRef } from "./checkpoint/ref.js";
11
+ import { toCheckpointRegistrySpec, writeCheckpointRegistryAtomic } from "./checkpoint/registry.js";
5
12
  import { Wait } from "./wait.js";
6
13
  import { registerSyncCleanup, unregisterSyncCleanup } from "./cleanup.js";
7
14
  import { Backends } from "./backends.js";
15
+ import { trackSandbox, untrackSandbox, trackNetwork } from "./reaper/init.js";
16
+ import { cacheDir } from "./cache-dir.js";
17
+ import { ReuseWithNetworkError } from "./errors.js";
18
+ import { reuseEnabled } from "./reuse/env.js";
19
+ import { reuseHash, reuseName } from "./reuse/hash.js";
20
+ import { readRegistry, writeRegistryAtomic, removeRegistry } from "./reuse/registry.js";
21
+ /** Fails fast — before any backend call — on a relative `containerPath`: both backends require an absolute `NAME:/path` shape. */
22
+ function requireAbsoluteContainerPath(containerPath) {
23
+ if (!path.posix.isAbsolute(containerPath)) {
24
+ throw new RelativeContainerPathError(containerPath);
25
+ }
26
+ }
8
27
  const MAX_START_ATTEMPTS = 5;
9
28
  let sequence = 0;
10
29
  function nextSequence() {
@@ -40,6 +59,22 @@ async function swallow(fn) {
40
59
  // Best-effort teardown: never let a stop/remove failure escape.
41
60
  }
42
61
  }
62
+ /** `Map<guestPort, hostPort>` → the registry's `{"<guestPort>": <hostPort>}` shape — JSON object keys are always strings. */
63
+ function portsToRegistryRecord(ports) {
64
+ const record = {};
65
+ for (const [guestPort, hostPort] of ports) {
66
+ record[String(guestPort)] = hostPort;
67
+ }
68
+ return record;
69
+ }
70
+ /** The inverse of `portsToRegistryRecord`. */
71
+ function registryRecordToPorts(record) {
72
+ const ports = new Map();
73
+ for (const [guestPort, hostPort] of Object.entries(record)) {
74
+ ports.set(Number(guestPort), hostPort);
75
+ }
76
+ return ports;
77
+ }
43
78
  /**
44
79
  * The builder, launcher, and lifecycle guard for a single container — the
45
80
  * one class every module (`RedisContainer`, `PostgresContainer`, …)
@@ -69,10 +104,15 @@ export class GenericContainer {
69
104
  waitStrategy = Wait.forListeningPort();
70
105
  memoryLimitMb;
71
106
  backendOverride;
107
+ reuseRequested = false;
108
+ requireIsolationRequested = false;
109
+ checkpointRef;
110
+ checkpointSourceBackend;
72
111
  handle;
73
112
  backend;
74
113
  mappedPorts = new Map();
75
114
  running = false;
115
+ installedNetworkLinks = [];
76
116
  /** Builds against `image` (e.g. `"redis:8.6-alpine"`); no I/O happens until `start()`. */
77
117
  constructor(image) {
78
118
  this.image = image;
@@ -81,6 +121,40 @@ export class GenericContainer {
81
121
  static async start(image) {
82
122
  return new GenericContainer(image).start();
83
123
  }
124
+ /**
125
+ * Builds a normal, ephemeral `GenericContainer` from a `Checkpoint`
126
+ * (`checkpoint()`'s return value): image is `cp.ref`, and
127
+ * env/command/exposed ports/memory limit default to `cp.spec` — chain
128
+ * further builder calls (a different `waitingFor`, `withBackend`, …)
129
+ * before `start()`, the same as any other container. Never carries over
130
+ * `cp.spec`'s network/aliases/mounts — the captured state already has the
131
+ * filesystem baked in, and (as with `withReuse()`'s own network
132
+ * restriction) network topology is never part of what a checkpoint
133
+ * captures. `start()` throws `CheckpointBackendMismatchError` before any
134
+ * backend call if the active backend isn't the one that created `cp`, and
135
+ * `ReuseFromCheckpointError` if this container is also marked
136
+ * `withReuse()` — reuse's identity hash never covers a checkpoint ref.
137
+ * Once started, a restored container is ordinary in every other respect:
138
+ * fresh host ports, normal reaping-ledger tracking, normal stop.
139
+ */
140
+ static fromCheckpoint(cp) {
141
+ const container = new GenericContainer(cp.ref);
142
+ container.checkpointRef = cp.ref;
143
+ container.checkpointSourceBackend = cp.backend;
144
+ for (const [key, value] of cp.spec.env) {
145
+ container.withEnv(key, value);
146
+ }
147
+ if (cp.spec.command !== undefined) {
148
+ container.withCommand(...cp.spec.command);
149
+ }
150
+ if (cp.spec.ports.length > 0) {
151
+ container.withExposedPorts(...cp.spec.ports.map((p) => p.guestPort));
152
+ }
153
+ if (cp.spec.memoryLimitMb !== undefined) {
154
+ container.withMemoryLimit(cp.spec.memoryLimitMb);
155
+ }
156
+ return container;
157
+ }
84
158
  /** Sets an environment variable visible to the workload. Last-write-wins if called again with the same key; insertion order is otherwise preserved. */
85
159
  withEnv(key, value) {
86
160
  // Last-write-wins, insertion-ordered: drop any prior entry for this key
@@ -130,6 +204,32 @@ export class GenericContainer {
130
204
  this.backendOverride = backend;
131
205
  return this;
132
206
  }
207
+ /**
208
+ * Marks this container for reuse: survive process exit and be ADOPTED —
209
+ * not re-created — by the next equivalent container's `start()`, in this
210
+ * process or a later one. Double opt-in: this API marker alone does
211
+ * nothing unless `RIGHTSIZE_REUSE` is also `"true"` or `"1"` at `start()`
212
+ * time. Marked-but-not-enabled starts as an ordinary ephemeral container
213
+ * (Testcontainers semantics), with a one-time stderr note. See the
214
+ * [reuse guide](/guide/reuse) for identity rules, the network
215
+ * restriction, and manual cleanup.
216
+ */
217
+ withReuse() {
218
+ this.reuseRequested = true;
219
+ return this;
220
+ }
221
+ /**
222
+ * Demands hardware-virtualized isolation: `start()` throws
223
+ * `IsolationRequiredError` before any create/network work if the active
224
+ * backend's `capabilities.hardwareIsolated` is `false` (the docker
225
+ * fallback, which shares the host kernel), instead of silently degrading.
226
+ * Use for tests that run untrusted code. See the
227
+ * [isolation guide](/guide/isolation).
228
+ */
229
+ withRequireIsolation() {
230
+ this.requireIsolationRequested = true;
231
+ return this;
232
+ }
133
233
  /** Module hook: override to adjust the spec once mapped host ports are known (e.g. advertised listeners). */
134
234
  customizeSpec(spec, _mapped) {
135
235
  return spec;
@@ -181,6 +281,44 @@ export class GenericContainer {
181
281
  aliases: this.aliasNames,
182
282
  runId: RunId.value,
183
283
  memoryLimitMb: this.memoryLimitMb,
284
+ // This ephemeral path is never taken by an active reuse container —
285
+ // that's buildReuseSpec()'s job, always with keepAlive: true. A
286
+ // requested-but-env-disabled reuse container falls through to here
287
+ // deliberately (Testcontainers semantics), so it must stay false.
288
+ keepAlive: false,
289
+ checkpointRef: this.checkpointRef,
290
+ };
291
+ return this.customizeSpec(spec, (guest) => {
292
+ const p = ports.get(guest);
293
+ if (p === undefined) {
294
+ throw new Error(`no allocated host port for guest port ${guest}`);
295
+ }
296
+ return p;
297
+ });
298
+ }
299
+ /** The reuse-active counterpart of `buildSpec`: always `keepAlive: true`, named `rz-reuse-<hash12>` rather than the run-scoped `rz-<runId>-<seq>`, and never joined to a `Network` (rejected earlier in `start()`). */
300
+ buildReuseSpec(name, ports) {
301
+ const spec = {
302
+ name,
303
+ image: this.image,
304
+ env: this.envPairs.map(([k, v]) => [k, v]),
305
+ command: this.command,
306
+ ports: this.exposedPorts.map((guestPort) => ({
307
+ hostPort: ports.get(guestPort) ?? (() => {
308
+ throw new Error(`no allocated host port for guest port ${guestPort}`);
309
+ })(),
310
+ guestPort,
311
+ })),
312
+ mounts: this.mounts,
313
+ networkId: undefined,
314
+ aliases: this.aliasNames,
315
+ runId: RunId.value,
316
+ memoryLimitMb: this.memoryLimitMb,
317
+ keepAlive: true,
318
+ // Reuse + fromCheckpoint is rejected in start() before this is ever
319
+ // built (see ReuseFromCheckpointError) — always undefined in
320
+ // practice, present only to satisfy ContainerSpec's shape.
321
+ checkpointRef: this.checkpointRef,
184
322
  };
185
323
  return this.customizeSpec(spec, (guest) => {
186
324
  const p = ports.get(guest);
@@ -190,6 +328,17 @@ export class GenericContainer {
190
328
  return p;
191
329
  });
192
330
  }
331
+ /** This container's reuse identity hash — the reuse-relevant subset of its builder state, hashed per `reuseHash`'s canonical form. Reads every `withCopyFileToContainer` source file's CURRENT content, so a mutated source file between two `start()` calls changes identity. */
332
+ async computeReuseHash() {
333
+ return reuseHash({
334
+ image: this.image,
335
+ env: this.envPairs.map(([k, v]) => [k, v]),
336
+ command: this.command,
337
+ exposedPorts: this.exposedPorts,
338
+ memoryLimitMb: this.memoryLimitMb,
339
+ copies: this.mounts.map((m) => ({ guestPath: m.guestPath, hostPath: m.hostPath })),
340
+ });
341
+ }
193
342
  async allocatePorts() {
194
343
  const ports = new Map();
195
344
  for (const guestPort of this.exposedPorts) {
@@ -218,9 +367,49 @@ export class GenericContainer {
218
367
  */
219
368
  async start() {
220
369
  const backend = this.resolveBackend();
370
+ // Before any backend work at all: a checkpoint ref is only meaningful
371
+ // to the backend that minted it (a docker image tag means nothing to
372
+ // msb, a msb snapshot name means nothing to docker).
373
+ if (this.checkpointSourceBackend !== undefined && this.checkpointSourceBackend !== backend.name) {
374
+ throw new CheckpointBackendMismatchError(this.checkpointSourceBackend, backend.name);
375
+ }
376
+ // Only the env-resolved path (never an explicit withBackend() override)
377
+ // drives the reaper: writes this process's run record, sweeps dead runs
378
+ // on first use, and spawns the watchdog. Skipping it for overrides is
379
+ // deliberate (see the feature's own addendum) — the ledger's per-call
380
+ // no-op guard means every trackSandbox/trackNetwork call below is
381
+ // automatically inert for a process that never resolves a backend this
382
+ // way, so nothing else here needs to branch on backendOverride too.
383
+ if (this.backendOverride === undefined) {
384
+ await Backends.reaperReady();
385
+ }
386
+ // Checked before the containerIsStarting hook and before any
387
+ // network/port/create work — see withRequireIsolation()'s own doc. No
388
+ // sandbox is ever created for a rejected request.
389
+ if (this.requireIsolationRequested && !backend.capabilities.hardwareIsolated) {
390
+ throw new IsolationRequiredError(backend.name);
391
+ }
221
392
  await this.containerIsStarting(backend);
393
+ // Double opt-in (see withReuse()'s own doc): both the API marker AND
394
+ // RIGHTSIZE_REUSE must be set for reuse to actually engage. Requested-
395
+ // but-not-enabled falls straight through to the ordinary ephemeral flow
396
+ // below — ONE note on stderr, then Testcontainers-standard behavior.
397
+ if (this.reuseRequested) {
398
+ if (reuseEnabled(process.env)) {
399
+ if (this.network !== undefined) {
400
+ throw new ReuseWithNetworkError();
401
+ }
402
+ if (this.checkpointRef !== undefined) {
403
+ throw new ReuseFromCheckpointError();
404
+ }
405
+ return this.startReuse(backend);
406
+ }
407
+ process.stderr.write(`[rightsize] reuse was requested via withReuse() for '${this.image}' but RIGHTSIZE_REUSE is not ` +
408
+ `enabled — starting as an ordinary (non-reused) container.\n`);
409
+ }
222
410
  if (this.network !== undefined) {
223
411
  await backend.ensureNetwork(this.network.id);
412
+ await trackNetwork(this.network.id);
224
413
  }
225
414
  let handle;
226
415
  let allocatedPorts;
@@ -229,6 +418,15 @@ export class GenericContainer {
229
418
  const ports = await this.allocatePorts();
230
419
  const name = `rz-${RunId.value}-${nextSequence()}`;
231
420
  const spec = this.buildSpec(name, ports);
421
+ // Appended BEFORE create() — the ledger's `.sandboxes` file is always
422
+ // a superset of this run's live sandboxes, never a subset. `keepAlive`
423
+ // containers are never listed at all, so a sweep can never reach one
424
+ // — this path only ever builds a spec via buildSpec(), which is
425
+ // always keepAlive: false (see its own doc); an active reuse
426
+ // container takes the entirely separate startReuse() path below.
427
+ if (!spec.keepAlive) {
428
+ await trackSandbox(name);
429
+ }
232
430
  let createdHandle;
233
431
  try {
234
432
  createdHandle = await backend.create(spec);
@@ -242,6 +440,9 @@ export class GenericContainer {
242
440
  await swallow(() => backend.stop(createdHandle));
243
441
  await swallow(() => backend.remove(createdHandle));
244
442
  }
443
+ if (!spec.keepAlive) {
444
+ await untrackSandbox(name);
445
+ }
245
446
  this.releasePorts(ports);
246
447
  if (isPortBindConflict(err)) {
247
448
  lastConflict = err;
@@ -264,11 +465,18 @@ export class GenericContainer {
264
465
  // hook in cleanup.ts calls straight into the backend's own blocking
265
466
  // cleanupSync — the same mechanism a crash-recovery reaper would find
266
467
  // necessary, just invoked proactively instead of on the next process's
267
- // startup sweep.
268
- registerSyncCleanup(handle.id, () => backend.cleanupSync(handle.id));
468
+ // startup sweep. `keepAlive` containers are kept OUT of this registry
469
+ // entirely, the same way they're kept out of the ledger above: they're
470
+ // meant to outlive this process, so the exit path must never tear one
471
+ // down — see the note on the ledger append above for why this path
472
+ // never sees keepAlive: true in practice.
473
+ if (!handle.spec.keepAlive) {
474
+ registerSyncCleanup(handle, backend, () => backend.cleanupSync(handle.id));
475
+ }
269
476
  try {
270
477
  const links = this.network?.linksForNewMember() ?? [];
271
478
  await backend.installNetworkLinks(handle, links);
479
+ this.installedNetworkLinks = links;
272
480
  // Register AFTER links are computed/installed — a container must
273
481
  // never see itself in its own linksForNewMember() call.
274
482
  if (this.network !== undefined) {
@@ -286,6 +494,209 @@ export class GenericContainer {
286
494
  await this.containerIsStarted();
287
495
  return this;
288
496
  }
497
+ /**
498
+ * The reuse-active start path — an entirely separate flow from the
499
+ * ephemeral one above, never joining `MAX_START_ATTEMPTS`'s port-conflict
500
+ * retry loop, the ledger, or the sync-cleanup registry (all deliberate:
501
+ * see `withReuse()`'s own doc and the reaping addendum). Computes this
502
+ * container's identity hash, then adopts an existing sandbox or creates a
503
+ * fresh one under the deterministic `rz-reuse-<hash12>` name.
504
+ */
505
+ async startReuse(backend) {
506
+ const dir = cacheDir();
507
+ const hash = await this.computeReuseHash();
508
+ const name = reuseName(hash);
509
+ const { handle, mappedPorts } = await this.adoptOrCreate(backend, dir, hash, name, false);
510
+ this.handle = handle;
511
+ this.backend = backend;
512
+ this.mappedPorts = mappedPorts;
513
+ this.running = true;
514
+ // Deliberately no registerSyncCleanup and no ledger trackSandbox call —
515
+ // a keepAlive sandbox must outlive this process, which is exactly what
516
+ // both of those exist to prevent (see start()'s own doc on the
517
+ // ephemeral path for the symmetric reasoning).
518
+ await this.containerIsStarted();
519
+ return this;
520
+ }
521
+ /**
522
+ * Reads the registry once and either adopts (registry hit, running,
523
+ * wait ok) or falls through to a fresh create — see the reuse spec's own
524
+ * "Start flow when reuse is active" for the full state table this
525
+ * implements. `isRetry` caps the name-collision recursion at one retry
526
+ * (`adoptOrCreate` re-enters itself exactly once, from the create/start
527
+ * failure branch below): "another process won the race" is expected to
528
+ * resolve within one retry (by then its own registry write has very
529
+ * likely landed); anything still colliding after that is a genuine
530
+ * failure, not a race, and propagates.
531
+ */
532
+ async adoptOrCreate(backend, dir, hash, name, isRetry) {
533
+ const read = await readRegistry(dir, hash);
534
+ if (read.kind === "found") {
535
+ const adopted = await this.tryAdopt(backend, dir, hash, name, read.entry);
536
+ if (adopted !== undefined) {
537
+ return adopted;
538
+ }
539
+ // tryAdopt already best-effort-removed the stale sandbox + registry
540
+ // file; fall through to a fresh create below, same as "missing".
541
+ }
542
+ else if (read.kind === "corrupt") {
543
+ // Unparseable: don't trust anything about what's there, but the name
544
+ // is deterministic from `hash` regardless of registry content, so a
545
+ // best-effort removal is still possible — see the reuse spec's
546
+ // stale/corrupt-registry fallback.
547
+ await this.cleanupStaleReuse(backend, dir, hash, name);
548
+ }
549
+ else {
550
+ // read.kind === "missing": the common first-time-ever case, but ALSO
551
+ // exactly the crash-mid-boot orphan window (see the reuse guide's
552
+ // "recovering from a crash mid-boot" note): the registry entry is
553
+ // only ever written AFTER a fresh-created reuse sandbox passes its
554
+ // OWN wait strategy, so a process that crashed — or failed that wait
555
+ // — between create() and that write can leave a sandbox RUNNING under
556
+ // this exact deterministic name with no registry entry to say so.
557
+ // `keepAlive` makes it invisible to reaping, so nothing else in this
558
+ // library will ever clear it. Ask the backend directly whether that
559
+ // name is live before racing a fresh create() against it.
560
+ await this.removeOrphanedRunningReuse(backend, name);
561
+ }
562
+ // Same host-port-bind-conflict retry as the ephemeral flow in start()
563
+ // (see MAX_START_ATTEMPTS there): a pre-allocated port can still lose an
564
+ // allocate-then-bind race to an unrelated process, and a reuse create is
565
+ // no less exposed to that than an ephemeral one.
566
+ let handle;
567
+ let allocatedPorts;
568
+ let lastConflict;
569
+ for (let attempt = 0; attempt < MAX_START_ATTEMPTS; attempt++) {
570
+ const ports = await this.allocatePorts();
571
+ const spec = this.buildReuseSpec(name, ports);
572
+ let createdHandle;
573
+ try {
574
+ createdHandle = await backend.create(spec);
575
+ await backend.start(createdHandle);
576
+ handle = createdHandle;
577
+ allocatedPorts = ports;
578
+ break;
579
+ }
580
+ catch (err) {
581
+ if (createdHandle !== undefined) {
582
+ await swallow(() => backend.stop(createdHandle));
583
+ await swallow(() => backend.remove(createdHandle));
584
+ }
585
+ this.releasePorts(ports);
586
+ if (isPortBindConflict(err)) {
587
+ lastConflict = err;
588
+ continue;
589
+ }
590
+ if (!isRetry) {
591
+ // "Another process won the race": if the name is running NOW,
592
+ // this failure was very likely that collision rather than a
593
+ // genuine backend error — re-enter the adopt path once rather
594
+ // than surfacing a spurious create() failure. A create/start
595
+ // failure for an unrelated reason leaves findRunning reporting
596
+ // nothing, so the original error still propagates below.
597
+ const runningNow = await backend.findRunning(spec).catch(() => undefined);
598
+ if (runningNow !== undefined) {
599
+ return this.adoptOrCreate(backend, dir, hash, name, true);
600
+ }
601
+ }
602
+ throw err;
603
+ }
604
+ }
605
+ if (handle === undefined || allocatedPorts === undefined) {
606
+ 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 });
607
+ }
608
+ const ports = allocatedPorts;
609
+ try {
610
+ await this.waitStrategy.waitUntilReady(this.waitTargetFor(backend, handle, ports));
611
+ }
612
+ catch (err) {
613
+ await swallow(() => backend.stop(handle));
614
+ await swallow(() => backend.remove(handle));
615
+ this.releasePorts(ports);
616
+ throw err;
617
+ }
618
+ const entry = {
619
+ name,
620
+ image: this.image,
621
+ ports: portsToRegistryRecord(ports),
622
+ createdIso: new Date().toISOString(),
623
+ backend: backend.name,
624
+ };
625
+ await writeRegistryAtomic(dir, hash, entry);
626
+ return { handle, mappedPorts: ports };
627
+ }
628
+ /**
629
+ * Registry hit → verify: confirm the name is actually running (backend
630
+ * query, never trusted from the file alone), then re-run this
631
+ * container's OWN wait strategy against the recorded ports — a running
632
+ * sandbox that never became ready for THIS workload is exactly as
633
+ * unadoptable as one that isn't running at all. `undefined` means adopt
634
+ * failed for any reason (not running, or the wait failed); the caller's
635
+ * best-effort cleanup already ran by the time this returns.
636
+ */
637
+ async tryAdopt(backend, dir, hash, name, entry) {
638
+ const mappedPorts = registryRecordToPorts(entry.ports);
639
+ const spec = this.buildReuseSpec(name, mappedPorts);
640
+ const handle = await backend.findRunning(spec).catch(() => undefined);
641
+ if (handle === undefined) {
642
+ await this.cleanupStaleReuse(backend, dir, hash, name);
643
+ return undefined;
644
+ }
645
+ try {
646
+ await this.waitStrategy.waitUntilReady(this.waitTargetFor(backend, handle, mappedPorts));
647
+ }
648
+ catch {
649
+ await this.cleanupStaleReuse(backend, dir, hash, name);
650
+ return undefined;
651
+ }
652
+ return { handle, mappedPorts };
653
+ }
654
+ /** 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. */
655
+ async cleanupStaleReuse(backend, dir, hash, name) {
656
+ await swallow(() => backend.removeByName(name));
657
+ await removeRegistry(dir, hash);
658
+ }
659
+ /**
660
+ * The crash-mid-boot-orphan guard for the "no registry entry at all"
661
+ * fresh-create path: confirms with the backend whether `name` is actually
662
+ * running BEFORE this container's own create() attempt, and only then
663
+ * best-effort-removes it — never a blind `removeByName` the way
664
+ * `cleanupStaleReuse` above does for the corrupt/failed-verification
665
+ * cases, where a registry entry (however stale) already proved something
666
+ * about this identity once existed. Here nothing has ever proved that, so
667
+ * the common empty-cache case (nothing running) pays no removal call at
668
+ * all, and a concurrent creator that's already past ITS OWN create()-and-
669
+ * register cycle by the time this runs is instead caught by the
670
+ * name-collision-retry-into-adopt path below (a registry entry appearing
671
+ * for it means that process won the race — adopt, don't remove). The spec
672
+ * handed to `findRunning` here is a throwaway: any exposed ports serve
673
+ * only to satisfy `ContainerSpec`'s shape, since every backend's
674
+ * `findRunning` matches on `spec.name` alone.
675
+ */
676
+ async removeOrphanedRunningReuse(backend, name) {
677
+ const probePorts = new Map(this.exposedPorts.map((guestPort) => [guestPort, 0]));
678
+ const probeSpec = this.buildReuseSpec(name, probePorts);
679
+ const running = await backend.findRunning(probeSpec).catch(() => undefined);
680
+ if (running !== undefined) {
681
+ await swallow(() => backend.removeByName(name));
682
+ }
683
+ }
684
+ /** The `WaitTarget` for a specific (backend, handle, ports) triple, independent of this instance's own `this.handle`/`this.backend`/`this.mappedPorts` — needed because reuse's adopt-verification wait runs BEFORE those fields are ever assigned. */
685
+ waitTargetFor(backend, handle, ports) {
686
+ return {
687
+ host: this.host,
688
+ mappedPort: (guestPort) => {
689
+ const p = ports.get(guestPort);
690
+ if (p === undefined) {
691
+ throw new Error(`Port ${guestPort} is not exposed — call withExposedPorts(${guestPort}).`);
692
+ }
693
+ return p;
694
+ },
695
+ exposedGuestPorts: this.exposedPorts,
696
+ currentLogs: () => backend.logs(handle),
697
+ describe: () => `container '${handle.spec.name}' (image ${this.image})`,
698
+ };
699
+ }
289
700
  /**
290
701
  * Stops and removes the backend-native resource, releases its host ports,
291
702
  * and clears the port map. Idempotent — stopping a container that never
@@ -300,9 +711,26 @@ export class GenericContainer {
300
711
  const handle = this.handle;
301
712
  const backend = this.backend;
302
713
  this.running = false;
714
+ // keepAlive (reuse) is the one case that is NOT teardown: the sandbox
715
+ // is deliberately LEFT RUNNING — that's the whole feature — so none of
716
+ // the backend-native stop/remove, the sync-cleanup registration (never
717
+ // made in the first place, see start()), or the ledger untracking
718
+ // (never tracked in the first place) apply here. Only this instance's
719
+ // OWN bookkeeping (running flag, port map) is cleared below.
720
+ if (handle.spec.keepAlive) {
721
+ // The sandbox's host ports stay genuinely bound for as long as it
722
+ // keeps running (indefinitely, past this call) — returning them to
723
+ // FreePorts here would let an unrelated later allocation in THIS
724
+ // process collide with a port that is still very much in use. They
725
+ // simply stay marked issued for the rest of this process's lifetime;
726
+ // only this instance's own view of them is cleared below.
727
+ this.mappedPorts = new Map();
728
+ return;
729
+ }
303
730
  await swallow(() => backend.stop(handle));
304
731
  await swallow(() => backend.remove(handle));
305
732
  unregisterSyncCleanup(handle.id);
733
+ await untrackSandbox(handle.spec.name);
306
734
  this.releasePorts(this.mappedPorts);
307
735
  this.mappedPorts = new Map();
308
736
  }
@@ -352,6 +780,141 @@ export class GenericContainer {
352
780
  }
353
781
  return { handle: this.handle, backend: this.backend };
354
782
  }
783
+ /**
784
+ * Captures this running container's state and returns a `Checkpoint` — a
785
+ * FILESYSTEM capture, not a memory snapshot: `fromCheckpoint()` boots a
786
+ * container from the captured state with processes restarting from
787
+ * scratch, not resuming. Requires the active backend's
788
+ * `capabilities.checkpoint` (`true` on both real backends today: docker
789
+ * via image commit, microsandbox via disk snapshot) — checked BEFORE any
790
+ * backend call, so an unsupported backend never attempts one. Throws a
791
+ * state error (same shape as `exec`/`logs`) if this container isn't
792
+ * currently running.
793
+ *
794
+ * Passing `name` makes this checkpoint NAMED and durable: the ref becomes
795
+ * deterministic (`rz-ckpt-<name>` / `rightsize/checkpoint:<name>` instead
796
+ * of a random 12-hex suffix), and — only once the backend call below has
797
+ * actually succeeded — a registry entry is written under
798
+ * `<cacheDir>/checkpoints/<name>.json` that `Checkpoints.find`/`list`/
799
+ * `remove` can rediscover later, in this process or a different one. `name`
800
+ * must match `^[a-z0-9][a-z0-9-]{0,40}$`; an invalid name throws
801
+ * `InvalidCheckpointNameError` before any backend call. Checkpointing under
802
+ * a name that already has a registry entry REPLACES it: the ref is the
803
+ * same deterministic value either way, so this best-effort clears the old
804
+ * artifact under that ref before creating the new one, then overwrites the
805
+ * registry entry — the latest checkpoint under a name always wins.
806
+ * Omitting `name` keeps the original behavior byte-for-byte: a random ref,
807
+ * no registry entry, ephemeral.
808
+ *
809
+ * On a backend whose `capabilities.checkpointRestartsWorkload` is `true`
810
+ * (microsandbox: the stop/snapshot/reboot cycle boots a fresh microVM),
811
+ * this re-runs the container's own wait strategy before returning — a bare
812
+ * return right after the backend call would hand back a false-ready
813
+ * container. docker's commit-to-image never disturbs the running
814
+ * container, so no re-wait happens there.
815
+ *
816
+ * That same reboot also kills any network links this container had
817
+ * installed toward already-running siblings (msb emulates them with
818
+ * exec-tunnels, which the workload restart tears down along with
819
+ * everything else on the sandbox), so when links were installed in the
820
+ * first place, this re-runs `installNetworkLinks` with those same links
821
+ * BEFORE the wait-strategy re-run — the wait must gate a container that is
822
+ * fully re-linked, not just booted.
823
+ *
824
+ * Checkpoints are never auto-reaped (a committed image or a disk snapshot
825
+ * is not a container) — see the checkpoints guide for the manual cleanup
826
+ * one-liners and the `Checkpoints.remove` cleanup affordance.
827
+ */
828
+ async checkpoint(name) {
829
+ if (name !== undefined) {
830
+ requireValidCheckpointName(name);
831
+ }
832
+ const { handle, backend } = this.requireHandle();
833
+ if (!backend.capabilities.checkpoint) {
834
+ throw new CheckpointUnsupportedError(backend.name);
835
+ }
836
+ const ref = checkpointRef(backend.name, name);
837
+ if (name !== undefined) {
838
+ // Replace semantics: the ref is deterministic from `name`, so a prior
839
+ // checkpoint under this same name — if any — sits under this exact
840
+ // ref. Best-effort clear it before creating the new one.
841
+ await swallow(() => backend.removeCheckpoint(ref));
842
+ }
843
+ await backend.createCheckpoint(handle, ref);
844
+ if (backend.capabilities.checkpointRestartsWorkload) {
845
+ if (this.installedNetworkLinks.length > 0) {
846
+ await backend.installNetworkLinks(handle, this.installedNetworkLinks);
847
+ }
848
+ await this.waitStrategy.waitUntilReady(this.asWaitTarget());
849
+ }
850
+ if (name !== undefined) {
851
+ // Only after the backend checkpoint above has actually succeeded — a
852
+ // failed createCheckpoint() already threw, so a registry entry is
853
+ // never written for a checkpoint that doesn't exist.
854
+ const entry = {
855
+ name,
856
+ ref,
857
+ backend: backend.name,
858
+ createdIso: new Date().toISOString(),
859
+ spec: toCheckpointRegistrySpec(handle.spec),
860
+ };
861
+ await writeCheckpointRegistryAtomic(cacheDir(), name, entry);
862
+ }
863
+ return { ref, backend: backend.name, spec: handle.spec };
864
+ }
865
+ /**
866
+ * Copies a host file or directory into this running container at
867
+ * `containerPath`, creating the destination's parent directory first
868
+ * (`exec: mkdir -p <parent>`) — callers never pre-create directories on
869
+ * either side. `containerPath` must be absolute (both backends' own copy
870
+ * tools require `NAME:/abs/path`); `hostPath` may be a file or a
871
+ * directory, `cp -r`-style: copying a directory to an absent destination
872
+ * produces that destination as a copy of the source's CONTENTS, not the
873
+ * source nested one level down. Requires a running container — throws the
874
+ * same state error `exec()`/`logs()` do, before any backend call. Works on
875
+ * a reuse container too, but the copy mutates shared state that is NOT
876
+ * part of the reuse identity hash — see the reuse guide's caveat.
877
+ */
878
+ async copyFileToContainer(hostPath, containerPath) {
879
+ const { handle, backend } = this.requireHandle();
880
+ requireAbsoluteContainerPath(containerPath);
881
+ const parent = path.posix.dirname(containerPath);
882
+ const mkdir = await backend.exec(handle, ["mkdir", "-p", parent]);
883
+ if (mkdir.exitCode !== 0) {
884
+ throw new BackendError(`could not create parent directory '${parent}' in '${handle.spec.name}' before copying in: ${mkdir.stderr.trim()}`);
885
+ }
886
+ await backend.copyToContainer(handle, hostPath, containerPath);
887
+ }
888
+ /**
889
+ * Convenience over `copyFileToContainer()`: writes `content` to a private
890
+ * (mode `0600`) temp file and copies THAT file in, removing the temp file
891
+ * afterward regardless of outcome. No streaming protocol — content is
892
+ * always fully materialized on host disk before the copy.
893
+ */
894
+ async copyContentToContainer(content, containerPath) {
895
+ const tempPath = path.join(os.tmpdir(), `rightsize-copy-content-${randomBytes(6).toString("hex")}`);
896
+ await fs.writeFile(tempPath, content, { mode: 0o600 });
897
+ try {
898
+ await this.copyFileToContainer(tempPath, containerPath);
899
+ }
900
+ finally {
901
+ await fs.rm(tempPath, { force: true });
902
+ }
903
+ }
904
+ /**
905
+ * Copies a file or directory OUT of this running container at
906
+ * `containerPath` to `hostPath`, creating the destination's host parent
907
+ * directory first via the standard library — the mirror image of
908
+ * `copyFileToContainer`'s guest-side `mkdir -p`. `containerPath` must be
909
+ * absolute; `cp -r`-style destination naming applies here too. Requires a
910
+ * running container, like `copyFileToContainer`.
911
+ */
912
+ async copyFileFromContainer(containerPath, hostPath) {
913
+ const { handle, backend } = this.requireHandle();
914
+ requireAbsoluteContainerPath(containerPath);
915
+ await fs.mkdir(path.dirname(hostPath), { recursive: true });
916
+ await backend.copyFromContainer(handle, containerPath, hostPath);
917
+ }
355
918
  /** Runs a one-shot command inside the running container and waits for it to exit. Throws if the container is not running. */
356
919
  async exec(...cmd) {
357
920
  const { handle, backend } = this.requireHandle();