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.
- package/README.md +36 -4
- package/dist/backend-docker/backend.d.ts +76 -1
- package/dist/backend-docker/backend.d.ts.map +1 -1
- package/dist/backend-docker/backend.js +156 -13
- package/dist/backend-docker/backend.js.map +1 -1
- package/dist/backend-docker/cli.d.ts +25 -0
- package/dist/backend-docker/cli.d.ts.map +1 -0
- package/dist/backend-docker/cli.js +64 -0
- package/dist/backend-docker/cli.js.map +1 -0
- package/dist/backend-docker/labels.d.ts +27 -5
- package/dist/backend-docker/labels.d.ts.map +1 -1
- package/dist/backend-docker/labels.js +33 -5
- package/dist/backend-docker/labels.js.map +1 -1
- package/dist/backend-msb/backend.d.ts +89 -6
- package/dist/backend-msb/backend.d.ts.map +1 -1
- package/dist/backend-msb/backend.js +179 -14
- package/dist/backend-msb/backend.js.map +1 -1
- package/dist/backend-msb/commands.d.ts +10 -0
- package/dist/backend-msb/commands.d.ts.map +1 -1
- package/dist/backend-msb/commands.js +29 -1
- package/dist/backend-msb/commands.js.map +1 -1
- package/dist/backend-msb/provider.d.ts.map +1 -1
- package/dist/backend-msb/provider.js +7 -11
- package/dist/backend-msb/provider.js.map +1 -1
- package/dist/backend-msb/provisioner.d.ts.map +1 -1
- package/dist/backend-msb/provisioner.js +1 -20
- package/dist/backend-msb/provisioner.js.map +1 -1
- package/dist/backend-msb/snapshot-not-found.d.ts +25 -0
- package/dist/backend-msb/snapshot-not-found.d.ts.map +1 -0
- package/dist/backend-msb/snapshot-not-found.js +27 -0
- package/dist/backend-msb/snapshot-not-found.js.map +1 -0
- package/dist/core/backend.d.ts +124 -0
- package/dist/core/backend.d.ts.map +1 -1
- package/dist/core/backends.d.ts +16 -2
- package/dist/core/backends.d.ts.map +1 -1
- package/dist/core/backends.js +39 -5
- package/dist/core/backends.js.map +1 -1
- package/dist/core/cache-dir.d.ts +17 -0
- package/dist/core/cache-dir.d.ts.map +1 -0
- package/dist/core/cache-dir.js +29 -0
- package/dist/core/cache-dir.js.map +1 -0
- package/dist/core/checkpoint/api.d.ts +80 -0
- package/dist/core/checkpoint/api.d.ts.map +1 -0
- package/dist/core/checkpoint/api.js +138 -0
- package/dist/core/checkpoint/api.js.map +1 -0
- package/dist/core/checkpoint/name.d.ts +12 -0
- package/dist/core/checkpoint/name.d.ts.map +1 -0
- package/dist/core/checkpoint/name.js +17 -0
- package/dist/core/checkpoint/name.js.map +1 -0
- package/dist/core/checkpoint/ref.d.ts +12 -0
- package/dist/core/checkpoint/ref.d.ts.map +1 -0
- package/dist/core/checkpoint/ref.js +16 -0
- package/dist/core/checkpoint/ref.js.map +1 -0
- package/dist/core/checkpoint/registry.d.ts +107 -0
- package/dist/core/checkpoint/registry.d.ts.map +1 -0
- package/dist/core/checkpoint/registry.js +180 -0
- package/dist/core/checkpoint/registry.js.map +1 -0
- package/dist/core/cleanup.d.ts +19 -4
- package/dist/core/cleanup.d.ts.map +1 -1
- package/dist/core/cleanup.js +14 -27
- package/dist/core/cleanup.js.map +1 -1
- package/dist/core/diagnostics.d.ts +33 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +76 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/errors.d.ts +97 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +129 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/generic-container.d.ts +176 -1
- package/dist/core/generic-container.d.ts.map +1 -1
- package/dist/core/generic-container.js +566 -3
- package/dist/core/generic-container.js.map +1 -1
- package/dist/core/model.d.ts +39 -0
- package/dist/core/model.d.ts.map +1 -1
- package/dist/core/network.d.ts.map +1 -1
- package/dist/core/network.js +2 -0
- package/dist/core/network.js.map +1 -1
- package/dist/core/reaper/env.d.ts +14 -0
- package/dist/core/reaper/env.d.ts.map +1 -0
- package/dist/core/reaper/env.js +22 -0
- package/dist/core/reaper/env.js.map +1 -0
- package/dist/core/reaper/init.d.ts +39 -0
- package/dist/core/reaper/init.d.ts.map +1 -0
- package/dist/core/reaper/init.js +140 -0
- package/dist/core/reaper/init.js.map +1 -0
- package/dist/core/reaper/ledger.d.ts +32 -0
- package/dist/core/reaper/ledger.d.ts.map +1 -0
- package/dist/core/reaper/ledger.js +160 -0
- package/dist/core/reaper/ledger.js.map +1 -0
- package/dist/core/reaper/process-liveness.d.ts +39 -0
- package/dist/core/reaper/process-liveness.d.ts.map +1 -0
- package/dist/core/reaper/process-liveness.js +117 -0
- package/dist/core/reaper/process-liveness.js.map +1 -0
- package/dist/core/reaper/run-record.d.ts +48 -0
- package/dist/core/reaper/run-record.d.ts.map +1 -0
- package/dist/core/reaper/run-record.js +58 -0
- package/dist/core/reaper/run-record.js.map +1 -0
- package/dist/core/reaper/sweep.d.ts +23 -0
- package/dist/core/reaper/sweep.d.ts.map +1 -0
- package/dist/core/reaper/sweep.js +76 -0
- package/dist/core/reaper/sweep.js.map +1 -0
- package/dist/core/reaper/watchdog.d.ts +100 -0
- package/dist/core/reaper/watchdog.d.ts.map +1 -0
- package/dist/core/reaper/watchdog.js +412 -0
- package/dist/core/reaper/watchdog.js.map +1 -0
- package/dist/core/reuse/env.d.ts +12 -0
- package/dist/core/reuse/env.d.ts.map +1 -0
- package/dist/core/reuse/env.js +15 -0
- package/dist/core/reuse/env.js.map +1 -0
- package/dist/core/reuse/hash.d.ts +34 -0
- package/dist/core/reuse/hash.d.ts.map +1 -0
- package/dist/core/reuse/hash.js +52 -0
- package/dist/core/reuse/hash.js.map +1 -0
- package/dist/core/reuse/registry.d.ts +50 -0
- package/dist/core/reuse/registry.d.ts.map +1 -0
- package/dist/core/reuse/registry.js +75 -0
- package/dist/core/reuse/registry.js.map +1 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/modules/keycloak.js +1 -1
- package/dist/modules/mysql.d.ts.map +1 -1
- package/dist/modules/mysql.js +4 -3
- package/dist/modules/mysql.js.map +1 -1
- package/package.json +1 -1
- package/dist/backend-msb/reaper.d.ts +0 -10
- package/dist/backend-msb/reaper.d.ts.map +0 -1
- package/dist/backend-msb/reaper.js +0 -73
- 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
|
-
|
|
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();
|