start-command 0.37.8 → 0.37.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/bin/cli.js +4 -0
- package/src/lib/args-parser-queries.js +45 -8
- package/src/lib/args-parser.js +10 -1
- package/src/lib/container-removal.js +163 -0
- package/src/lib/detached-finalize.js +29 -2
- package/src/lib/docker-cleanup.js +7 -4
- package/src/lib/docker-post-mortem.js +2 -0
- package/src/lib/docker-snapshot-resume.js +222 -0
- package/src/lib/docker-snapshot-safety.js +124 -12
- package/src/lib/execution-attempt.js +2 -0
- package/src/lib/execution-cleanup.js +193 -0
- package/src/lib/execution-control.js +2 -1
- package/src/lib/execution-recovery.js +2 -0
- package/src/lib/execution-resume.js +60 -6
- package/src/lib/execution-store.js +52 -0
- package/src/lib/isolation.js +4 -0
- package/src/lib/query-commands.js +16 -3
- package/src/lib/snapshot-resume-options.js +174 -0
- package/src/lib/status-formatter.js +59 -34
- package/src/lib/status-links-notation.js +97 -0
- package/src/lib/store-lock.js +3 -0
- package/src/lib/usage.js +7 -0
- package/test/regression-224.js +807 -0
- package/test/regression-225.js +415 -0
- package/test/regression-226.js +81 -0
- package/test/shared-golden.js +20 -0
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #224: a Docker snapshot resume (`$ --resume` of a
|
|
3
|
+
* container without a command handoff) could not wait for the snapshot lock
|
|
4
|
+
* or for disk, removed the original after a single instant check, offered no
|
|
5
|
+
* way to remove the snapshot images of a container the caller removed, said
|
|
6
|
+
* nothing during a long `docker commit`, always walked the layer with
|
|
7
|
+
* `docker inspect --size`, and had no hook to shrink the layer first.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { describe, it, expect, afterEach } = require('bun:test');
|
|
11
|
+
const { spawn, spawnSync } = require('child_process');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
17
|
+
const {
|
|
18
|
+
EX_TEMPFAIL,
|
|
19
|
+
acquireSnapshotLock,
|
|
20
|
+
preflightSnapshot,
|
|
21
|
+
} = require('../src/lib/docker-snapshot-safety');
|
|
22
|
+
const {
|
|
23
|
+
commitWithProgress,
|
|
24
|
+
formatElapsed,
|
|
25
|
+
} = require('../src/lib/docker-snapshot-resume');
|
|
26
|
+
const { resumeExecution } = require('../src/lib/execution-resume');
|
|
27
|
+
const {
|
|
28
|
+
chainImages,
|
|
29
|
+
cleanupExecution,
|
|
30
|
+
} = require('../src/lib/execution-cleanup');
|
|
31
|
+
const { SessionState } = require('../src/lib/session-probe');
|
|
32
|
+
const {
|
|
33
|
+
ExecutionRecord,
|
|
34
|
+
ExecutionStatus,
|
|
35
|
+
ExecutionStore,
|
|
36
|
+
} = require('../src/lib/execution-store');
|
|
37
|
+
|
|
38
|
+
const GiB = 1024 ** 3;
|
|
39
|
+
const tempDirs = [];
|
|
40
|
+
const children = [];
|
|
41
|
+
|
|
42
|
+
function makeTempDir(prefix) {
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
44
|
+
tempDirs.push(dir);
|
|
45
|
+
return dir;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
while (children.length > 0) {
|
|
50
|
+
children.pop().kill('SIGKILL');
|
|
51
|
+
}
|
|
52
|
+
while (tempDirs.length > 0) {
|
|
53
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const SAFETY = require.resolve('../src/lib/docker-snapshot-safety');
|
|
58
|
+
|
|
59
|
+
/** Hold the snapshot lock in another process for `holdMs`. */
|
|
60
|
+
async function holdLockElsewhere(lockPath, holder, holdMs) {
|
|
61
|
+
const ready = `${lockPath}.ready`;
|
|
62
|
+
const child = spawn(
|
|
63
|
+
process.execPath,
|
|
64
|
+
[
|
|
65
|
+
'-e',
|
|
66
|
+
`const lock = require(${JSON.stringify(SAFETY)}).acquireSnapshotLock({lockPath: ${JSON.stringify(lockPath)}, holder: ${JSON.stringify(holder)}});
|
|
67
|
+
require('fs').writeFileSync(${JSON.stringify(ready)}, '1');
|
|
68
|
+
setTimeout(() => { lock.release(); process.exit(0); }, ${holdMs});`,
|
|
69
|
+
],
|
|
70
|
+
{ stdio: 'ignore' }
|
|
71
|
+
);
|
|
72
|
+
children.push(child);
|
|
73
|
+
for (let i = 0; i < 200 && !fs.existsSync(ready); i++) {
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
75
|
+
}
|
|
76
|
+
expect(fs.existsSync(ready)).toBe(true);
|
|
77
|
+
return child;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function snapshotRecord(options = {}) {
|
|
81
|
+
let current = {
|
|
82
|
+
uuid: 'issue-224-uuid',
|
|
83
|
+
command: 'original',
|
|
84
|
+
status: 'executed',
|
|
85
|
+
exitCode: 137,
|
|
86
|
+
logPath: path.join(makeTempDir('log-224-'), 'execution.log'),
|
|
87
|
+
options: {
|
|
88
|
+
isolated: 'docker',
|
|
89
|
+
isolationMode: 'detached',
|
|
90
|
+
sessionName: 'box',
|
|
91
|
+
containerId: 'old-id',
|
|
92
|
+
...options,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
const store = {
|
|
96
|
+
get: () => globalThis.structuredClone(current),
|
|
97
|
+
save: (record) => {
|
|
98
|
+
current = globalThis.structuredClone(record);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
return { store, current: () => current };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Fake docker for a snapshot resume; `answers[subcommand]` may override a
|
|
106
|
+
* call by returning a result.
|
|
107
|
+
*/
|
|
108
|
+
function fakeDocker(calls, answers = {}) {
|
|
109
|
+
return (_bin, args) => {
|
|
110
|
+
calls.push(args);
|
|
111
|
+
const answer = answers[args[0]]?.(args);
|
|
112
|
+
if (answer) {
|
|
113
|
+
return answer;
|
|
114
|
+
}
|
|
115
|
+
const stdout =
|
|
116
|
+
args[0] === 'info'
|
|
117
|
+
? JSON.stringify({ DockerRootDir: '/docker' })
|
|
118
|
+
: args.includes('--size')
|
|
119
|
+
? String(GiB)
|
|
120
|
+
: args.includes('{{.State.Running}}')
|
|
121
|
+
? 'true'
|
|
122
|
+
: args.includes('{{json .HostConfig}}')
|
|
123
|
+
? '{}'
|
|
124
|
+
: args[0] === 'run'
|
|
125
|
+
? 'new-container-id'
|
|
126
|
+
: '';
|
|
127
|
+
return { success: true, stdout, stderr: '', status: 0 };
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const stopped = () => ({ state: SessionState.STOPPED, alive: false });
|
|
132
|
+
const plentyOfDisk = { freeBytes: () => 100 * GiB };
|
|
133
|
+
|
|
134
|
+
describe('issue #224: options', () => {
|
|
135
|
+
it('parses every new option in both forms and keeps them on --resume', () => {
|
|
136
|
+
const { wrapperOptions } = parseArgs([
|
|
137
|
+
'--resume',
|
|
138
|
+
'box',
|
|
139
|
+
'--snapshot-wait',
|
|
140
|
+
'600',
|
|
141
|
+
'--snapshot-disk-wait=1800',
|
|
142
|
+
'--remove-original-after',
|
|
143
|
+
'30',
|
|
144
|
+
'--snapshot-size-hint=12g',
|
|
145
|
+
'--snapshot-prepare',
|
|
146
|
+
'rm -rf /workspace/target',
|
|
147
|
+
]);
|
|
148
|
+
expect(wrapperOptions.snapshotWait).toBe(600);
|
|
149
|
+
expect(wrapperOptions.snapshotDiskWait).toBe(1800);
|
|
150
|
+
expect(wrapperOptions.removeOriginalAfter).toBe(30);
|
|
151
|
+
expect(wrapperOptions.snapshotSizeHint).toBe(12 * GiB);
|
|
152
|
+
expect(wrapperOptions.snapshotPrepare).toBe('rm -rf /workspace/target');
|
|
153
|
+
// A startup window is a removal request.
|
|
154
|
+
expect(wrapperOptions.removeOriginal).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('accepts fractions and plain byte counts', () => {
|
|
158
|
+
const { wrapperOptions } = parseArgs([
|
|
159
|
+
'--resume=box',
|
|
160
|
+
'--snapshot-wait=0.5',
|
|
161
|
+
'--snapshot-size-hint',
|
|
162
|
+
'1024',
|
|
163
|
+
]);
|
|
164
|
+
expect(wrapperOptions.snapshotWait).toBe(0.5);
|
|
165
|
+
expect(wrapperOptions.snapshotSizeHint).toBe(1024);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('rejects the options outside --resume and malformed values', () => {
|
|
169
|
+
for (const flag of [
|
|
170
|
+
'--snapshot-wait=1',
|
|
171
|
+
'--snapshot-disk-wait=1',
|
|
172
|
+
'--remove-original-after=1',
|
|
173
|
+
'--snapshot-size-hint=1',
|
|
174
|
+
'--snapshot-prepare=true',
|
|
175
|
+
]) {
|
|
176
|
+
const name = flag.split('=')[0];
|
|
177
|
+
expect(() => parseArgs([flag, '--', 'echo'])).toThrow(
|
|
178
|
+
`${name} is only valid with --resume`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
expect(() =>
|
|
182
|
+
parseArgs(['--resume', 'box', '--snapshot-wait', '-1'])
|
|
183
|
+
).toThrow(/requires a seconds argument/);
|
|
184
|
+
expect(() =>
|
|
185
|
+
parseArgs(['--resume', 'box', '--snapshot-wait=soon'])
|
|
186
|
+
).toThrow(/Invalid --snapshot-wait value/);
|
|
187
|
+
expect(() =>
|
|
188
|
+
parseArgs(['--resume', 'box', '--snapshot-size-hint=12 GiB'])
|
|
189
|
+
).toThrow(/Invalid --snapshot-size-hint value/);
|
|
190
|
+
expect(() => parseArgs(['--resume', 'box', '--snapshot-prepare='])).toThrow(
|
|
191
|
+
/non-empty command/
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('takes both waits from the environment unless a flag sets them', () => {
|
|
196
|
+
// Only these two keys are restored: replacing `process.env` itself would
|
|
197
|
+
// drop its case-insensitive keys on Windows (PATH vs Path, ComSpec) and
|
|
198
|
+
// break every later spawn in this Bun process.
|
|
199
|
+
const names = ['START_SNAPSHOT_LOCK_WAIT', 'START_SNAPSHOT_DISK_WAIT'];
|
|
200
|
+
const saved = names.map((name) => process.env[name]);
|
|
201
|
+
process.env.START_SNAPSHOT_LOCK_WAIT = '120';
|
|
202
|
+
process.env.START_SNAPSHOT_DISK_WAIT = '60';
|
|
203
|
+
try {
|
|
204
|
+
const fromEnv = parseArgs(['--resume', 'box']).wrapperOptions;
|
|
205
|
+
expect(fromEnv.snapshotWait).toBe(120);
|
|
206
|
+
expect(fromEnv.snapshotDiskWait).toBe(60);
|
|
207
|
+
const fromFlag = parseArgs([
|
|
208
|
+
'--resume',
|
|
209
|
+
'box',
|
|
210
|
+
'--snapshot-wait=5',
|
|
211
|
+
]).wrapperOptions;
|
|
212
|
+
expect(fromFlag.snapshotWait).toBe(5);
|
|
213
|
+
// Other modes ignore the variables.
|
|
214
|
+
expect(parseArgs(['--status', 'box']).wrapperOptions.snapshotWait).toBe(
|
|
215
|
+
null
|
|
216
|
+
);
|
|
217
|
+
process.env.START_SNAPSHOT_LOCK_WAIT = 'later';
|
|
218
|
+
expect(() => parseArgs(['--resume', 'box'])).toThrow(
|
|
219
|
+
/START_SNAPSHOT_LOCK_WAIT/
|
|
220
|
+
);
|
|
221
|
+
} finally {
|
|
222
|
+
names.forEach((name, index) => {
|
|
223
|
+
if (saved[index] === undefined) {
|
|
224
|
+
delete process.env[name];
|
|
225
|
+
} else {
|
|
226
|
+
process.env[name] = saved[index];
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('gives --cleanup and --cleanup-dry-run an optional execution', () => {
|
|
233
|
+
expect(parseArgs(['--cleanup']).wrapperOptions.cleanupTarget).toBe(null);
|
|
234
|
+
expect(parseArgs(['--cleanup', 'box']).wrapperOptions).toMatchObject({
|
|
235
|
+
cleanup: true,
|
|
236
|
+
cleanupDryRun: false,
|
|
237
|
+
cleanupTarget: 'box',
|
|
238
|
+
});
|
|
239
|
+
expect(parseArgs(['--cleanup-dry-run=box']).wrapperOptions).toMatchObject({
|
|
240
|
+
cleanup: true,
|
|
241
|
+
cleanupDryRun: true,
|
|
242
|
+
cleanupTarget: 'box',
|
|
243
|
+
});
|
|
244
|
+
expect(() => parseArgs(['--cleanup='])).toThrow(/requires a UUID/);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('issue #224 item 1: waiting for the snapshot lock', () => {
|
|
249
|
+
it('fails fast with exit code 75 and names the holder by default', async () => {
|
|
250
|
+
const lockPath = path.join(makeTempDir('lock-224-'), 'snapshot.lock');
|
|
251
|
+
await holdLockElsewhere(lockPath, 'task-a', 10000);
|
|
252
|
+
let error;
|
|
253
|
+
try {
|
|
254
|
+
acquireSnapshotLock({ lockPath, holder: 'task-b' });
|
|
255
|
+
} catch (caught) {
|
|
256
|
+
error = caught;
|
|
257
|
+
}
|
|
258
|
+
expect(error.exitCode).toBe(EX_TEMPFAIL);
|
|
259
|
+
expect(error.message).toContain('resume of task-a is in progress');
|
|
260
|
+
expect(error.message).toContain('--snapshot-wait');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('waits for the holder with --snapshot-wait and says so once', async () => {
|
|
264
|
+
const lockPath = path.join(makeTempDir('lock-224-'), 'snapshot.lock');
|
|
265
|
+
await holdLockElsewhere(lockPath, 'task-a', 1500);
|
|
266
|
+
const lines = [];
|
|
267
|
+
const started = Date.now();
|
|
268
|
+
const lock = acquireSnapshotLock({
|
|
269
|
+
lockPath,
|
|
270
|
+
holder: 'task-b',
|
|
271
|
+
waitSeconds: 20,
|
|
272
|
+
notify: (line) => lines.push(line),
|
|
273
|
+
});
|
|
274
|
+
try {
|
|
275
|
+
expect(Date.now() - started).toBeGreaterThan(900);
|
|
276
|
+
expect(lines).toEqual([
|
|
277
|
+
'[Resume] Waiting up to 20s for the snapshot of task-a to finish before snapshotting task-b.',
|
|
278
|
+
]);
|
|
279
|
+
// The new holder is published for the next waiter.
|
|
280
|
+
expect(JSON.parse(fs.readFileSync(lockPath, 'utf8')).holder).toBe(
|
|
281
|
+
'task-b'
|
|
282
|
+
);
|
|
283
|
+
} finally {
|
|
284
|
+
lock.release();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('gives up with exit code 75 when the wait runs out', async () => {
|
|
289
|
+
const lockPath = path.join(makeTempDir('lock-224-'), 'snapshot.lock');
|
|
290
|
+
await holdLockElsewhere(lockPath, 'task-a', 10000);
|
|
291
|
+
let error;
|
|
292
|
+
try {
|
|
293
|
+
acquireSnapshotLock({
|
|
294
|
+
lockPath,
|
|
295
|
+
holder: 'task-b',
|
|
296
|
+
waitSeconds: 1.5,
|
|
297
|
+
notify: () => {},
|
|
298
|
+
});
|
|
299
|
+
} catch (caught) {
|
|
300
|
+
error = caught;
|
|
301
|
+
}
|
|
302
|
+
expect(error.exitCode).toBe(EX_TEMPFAIL);
|
|
303
|
+
expect(error.message).toContain('Still busy after waiting 1.5s');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('serializes two concurrent CLI resumes instead of failing the second', async () => {
|
|
307
|
+
const lockPath = path.join(makeTempDir('lock-224-'), 'snapshot.lock');
|
|
308
|
+
await holdLockElsewhere(lockPath, 'task-a', 1200);
|
|
309
|
+
const { store } = snapshotRecord();
|
|
310
|
+
const calls = [];
|
|
311
|
+
const lines = [];
|
|
312
|
+
const result = await resumeExecution(store, 'box', {
|
|
313
|
+
command: 'true',
|
|
314
|
+
probe: stopped,
|
|
315
|
+
runner: fakeDocker(calls),
|
|
316
|
+
snapshotOptions: { ...plentyOfDisk, lockPath, waitSeconds: 10 },
|
|
317
|
+
notify: (line) => lines.push(line),
|
|
318
|
+
startWatcher: () => true,
|
|
319
|
+
});
|
|
320
|
+
expect(result.success).toBe(true);
|
|
321
|
+
expect(lines[0]).toContain('snapshot of task-a to finish');
|
|
322
|
+
expect(calls.some((args) => args[0] === 'commit')).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('reports exit code 75 from a busy lock without committing', async () => {
|
|
326
|
+
const lockPath = path.join(makeTempDir('lock-224-'), 'snapshot.lock');
|
|
327
|
+
await holdLockElsewhere(lockPath, 'task-a', 10000);
|
|
328
|
+
const { store, current } = snapshotRecord();
|
|
329
|
+
const calls = [];
|
|
330
|
+
const result = await resumeExecution(store, 'box', {
|
|
331
|
+
command: 'true',
|
|
332
|
+
probe: stopped,
|
|
333
|
+
runner: fakeDocker(calls),
|
|
334
|
+
snapshotOptions: { ...plentyOfDisk, lockPath },
|
|
335
|
+
startWatcher: () => true,
|
|
336
|
+
});
|
|
337
|
+
expect(result.success).toBe(false);
|
|
338
|
+
expect(result.exitCode).toBe(75);
|
|
339
|
+
expect(calls.some((args) => args[0] === 'commit')).toBe(false);
|
|
340
|
+
expect(current().options.sessionName).toBe('box');
|
|
341
|
+
const { reportResult } = require('../src/lib/query-commands');
|
|
342
|
+
const original = console.error;
|
|
343
|
+
console.error = () => {};
|
|
344
|
+
try {
|
|
345
|
+
expect(reportResult(result)).toBe(75);
|
|
346
|
+
} finally {
|
|
347
|
+
console.error = original;
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe('issue #224 item 2: waiting for disk', () => {
|
|
353
|
+
function clock() {
|
|
354
|
+
let now = 0;
|
|
355
|
+
return {
|
|
356
|
+
now: () => now,
|
|
357
|
+
sleep: (ms) => {
|
|
358
|
+
now += ms;
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
it('re-checks free space until it suffices, measuring the layer once', () => {
|
|
364
|
+
const calls = [];
|
|
365
|
+
const free = [5 * GiB, 8 * GiB, 40 * GiB];
|
|
366
|
+
const lines = [];
|
|
367
|
+
const result = preflightSnapshot('box', fakeDocker(calls), {
|
|
368
|
+
freeBytes: () => free.shift(),
|
|
369
|
+
diskWaitSeconds: 600,
|
|
370
|
+
diskPollSeconds: 10,
|
|
371
|
+
notify: (line) => lines.push(line),
|
|
372
|
+
...clock(),
|
|
373
|
+
});
|
|
374
|
+
expect(result.required).toBe(12 * GiB);
|
|
375
|
+
expect(calls.filter((args) => args.includes('--size'))).toHaveLength(1);
|
|
376
|
+
expect(lines).toHaveLength(1);
|
|
377
|
+
expect(lines[0]).toMatch(
|
|
378
|
+
/^\[Resume\] Waiting up to 600s for disk: Insufficient disk at \/docker: need 12\.00 GiB/
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('refuses with exit code 75 when the wait runs out, or at once without one', () => {
|
|
383
|
+
let error;
|
|
384
|
+
try {
|
|
385
|
+
preflightSnapshot('box', fakeDocker([]), {
|
|
386
|
+
freeBytes: () => GiB,
|
|
387
|
+
diskWaitSeconds: 30,
|
|
388
|
+
diskPollSeconds: 10,
|
|
389
|
+
notify: () => {},
|
|
390
|
+
...clock(),
|
|
391
|
+
});
|
|
392
|
+
} catch (caught) {
|
|
393
|
+
error = caught;
|
|
394
|
+
}
|
|
395
|
+
expect(error.exitCode).toBe(75);
|
|
396
|
+
expect(error.message).toContain('Still short after waiting 30s');
|
|
397
|
+
try {
|
|
398
|
+
preflightSnapshot('box', fakeDocker([]), { freeBytes: () => GiB });
|
|
399
|
+
} catch (caught) {
|
|
400
|
+
error = caught;
|
|
401
|
+
}
|
|
402
|
+
expect(error.exitCode).toBe(75);
|
|
403
|
+
expect(error.message).toContain('Docker snapshot resume refused');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('keeps exit code 1 for a layer it cannot measure', () => {
|
|
407
|
+
let error;
|
|
408
|
+
try {
|
|
409
|
+
preflightSnapshot(
|
|
410
|
+
'box',
|
|
411
|
+
fakeDocker([], {
|
|
412
|
+
inspect: () => ({ success: false, stdout: '', stderr: 'boom' }),
|
|
413
|
+
})
|
|
414
|
+
);
|
|
415
|
+
} catch (caught) {
|
|
416
|
+
error = caught;
|
|
417
|
+
}
|
|
418
|
+
expect(error.message).toContain('writable layer size');
|
|
419
|
+
expect(error.exitCode).toBe(undefined);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe('issue #224 item 3: --remove-original-after', () => {
|
|
424
|
+
it('removes the original only after the window, once the watcher is attached', async () => {
|
|
425
|
+
const { store } = snapshotRecord();
|
|
426
|
+
const calls = [];
|
|
427
|
+
const events = [];
|
|
428
|
+
const result = await resumeExecution(store, 'box', {
|
|
429
|
+
command: 'true',
|
|
430
|
+
probe: stopped,
|
|
431
|
+
runner: fakeDocker(calls),
|
|
432
|
+
removeOriginal: true,
|
|
433
|
+
removeOriginalAfter: 30,
|
|
434
|
+
snapshotOptions: plentyOfDisk,
|
|
435
|
+
notify: () => {},
|
|
436
|
+
sleep: async (ms) => {
|
|
437
|
+
events.push(`sleep ${ms}`);
|
|
438
|
+
},
|
|
439
|
+
startWatcher: () => {
|
|
440
|
+
events.push('watcher');
|
|
441
|
+
return true;
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
expect(result.success).toBe(true);
|
|
445
|
+
expect(events).toEqual(['watcher', 'sleep 30000']);
|
|
446
|
+
const running = calls.filter((args) => args.includes('{{.State.Running}}'));
|
|
447
|
+
expect(running).toEqual([
|
|
448
|
+
['inspect', '-f', '{{.State.Running}}', 'box-resume-1'],
|
|
449
|
+
]);
|
|
450
|
+
expect(calls.at(-1)).toEqual(['rm', 'box']);
|
|
451
|
+
expect(result.output).toContain('Stopped original container removed: box');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('keeps the original when the replacement died within the window', async () => {
|
|
455
|
+
const { store } = snapshotRecord();
|
|
456
|
+
const calls = [];
|
|
457
|
+
const result = await resumeExecution(store, 'box', {
|
|
458
|
+
command: 'true',
|
|
459
|
+
probe: stopped,
|
|
460
|
+
runner: fakeDocker(calls, {
|
|
461
|
+
inspect: (args) =>
|
|
462
|
+
args.includes('{{.State.Running}}')
|
|
463
|
+
? { success: true, stdout: 'false' }
|
|
464
|
+
: undefined,
|
|
465
|
+
}),
|
|
466
|
+
removeOriginal: true,
|
|
467
|
+
removeOriginalAfter: 5,
|
|
468
|
+
snapshotOptions: plentyOfDisk,
|
|
469
|
+
notify: () => {},
|
|
470
|
+
sleep: async () => {},
|
|
471
|
+
startWatcher: () => true,
|
|
472
|
+
});
|
|
473
|
+
expect(result.success).toBe(true);
|
|
474
|
+
expect(calls.some((args) => args[0] === 'rm')).toBe(false);
|
|
475
|
+
expect(result.output).toContain(
|
|
476
|
+
'Original container retained: the snapshot container is not running 5s after launch.'
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe('issue #224 item 4: $ --cleanup <execution>', () => {
|
|
482
|
+
function chainRecord(overrides = {}) {
|
|
483
|
+
return new ExecutionRecord({
|
|
484
|
+
command: 'true',
|
|
485
|
+
status: ExecutionStatus.EXECUTED,
|
|
486
|
+
exitCode: 0,
|
|
487
|
+
startTime: '2026-10-19T00:00:00.000Z',
|
|
488
|
+
endTime: '2026-10-19T00:00:10.000Z',
|
|
489
|
+
options: {
|
|
490
|
+
isolated: 'docker',
|
|
491
|
+
isolationMode: 'detached',
|
|
492
|
+
sessionName: 'box-resume-1-resume-2',
|
|
493
|
+
sessionNameHistory: ['box', 'box-resume-1'],
|
|
494
|
+
snapshotImage: 'start-command-resume/box-resume-1:2',
|
|
495
|
+
containerId: 'c0ffee',
|
|
496
|
+
},
|
|
497
|
+
...overrides,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function storeWith(record) {
|
|
502
|
+
const store = new ExecutionStore({
|
|
503
|
+
appFolder: makeTempDir('store-224-'),
|
|
504
|
+
useLinks: false,
|
|
505
|
+
});
|
|
506
|
+
store.save(record);
|
|
507
|
+
return store;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const gone = (kind) => (args) => ({
|
|
511
|
+
success: false,
|
|
512
|
+
stdout: '',
|
|
513
|
+
stderr: `Error response from daemon: No such ${kind}: ${args.at(-1)}`,
|
|
514
|
+
status: 1,
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('derives the images of a resume chain newest first', () => {
|
|
518
|
+
expect(chainImages('box-resume-1-resume-2')).toEqual([
|
|
519
|
+
'start-command-resume/box-resume-1:2',
|
|
520
|
+
'start-command-resume/box:1',
|
|
521
|
+
]);
|
|
522
|
+
expect(chainImages('box')).toEqual([]);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('removes the containers, then their snapshot images, never forced', () => {
|
|
526
|
+
const record = chainRecord();
|
|
527
|
+
const store = storeWith(record);
|
|
528
|
+
const calls = [];
|
|
529
|
+
const result = cleanupExecution(store, record.uuid, {
|
|
530
|
+
runner: fakeDocker(calls, {
|
|
531
|
+
inspect: (args) =>
|
|
532
|
+
args.at(-1) === 'box-resume-1'
|
|
533
|
+
? {
|
|
534
|
+
success: true,
|
|
535
|
+
stdout: 'start-command-resume/box:1\n',
|
|
536
|
+
}
|
|
537
|
+
: gone('container')(args),
|
|
538
|
+
rm: (args) =>
|
|
539
|
+
args.at(-1) === 'box'
|
|
540
|
+
? gone('container')(args)
|
|
541
|
+
: { success: true, stdout: '' },
|
|
542
|
+
}),
|
|
543
|
+
});
|
|
544
|
+
expect(result.success).toBe(true);
|
|
545
|
+
const removals = calls.filter((args) => ['rm', 'rmi'].includes(args[0]));
|
|
546
|
+
expect(removals).toEqual([
|
|
547
|
+
['rm', 'box-resume-1-resume-2'],
|
|
548
|
+
['rm', 'box-resume-1'],
|
|
549
|
+
['rm', 'box'],
|
|
550
|
+
['rmi', 'start-command-resume/box-resume-1:2'],
|
|
551
|
+
['rmi', 'start-command-resume/box:1'],
|
|
552
|
+
]);
|
|
553
|
+
expect(result.output.split('\n')).toEqual([
|
|
554
|
+
'Removed container box-resume-1-resume-2',
|
|
555
|
+
'Removed container box-resume-1',
|
|
556
|
+
'Already gone: container box',
|
|
557
|
+
'Removed image start-command-resume/box-resume-1:2',
|
|
558
|
+
'Removed image start-command-resume/box:1',
|
|
559
|
+
]);
|
|
560
|
+
// Issue #225: the status now says the container is gone.
|
|
561
|
+
expect(store.get(record.uuid).containerState).toBe('removed');
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('reports what Docker keeps and fails without forcing it', () => {
|
|
565
|
+
const record = chainRecord();
|
|
566
|
+
const store = storeWith(record);
|
|
567
|
+
const result = cleanupExecution(store, record.uuid, {
|
|
568
|
+
runner: fakeDocker([], {
|
|
569
|
+
inspect: gone('container'),
|
|
570
|
+
rm: () => ({ success: true, stdout: '' }),
|
|
571
|
+
rmi: (args) =>
|
|
572
|
+
args.at(-1).endsWith(':1')
|
|
573
|
+
? {
|
|
574
|
+
success: false,
|
|
575
|
+
stderr:
|
|
576
|
+
'Error response from daemon: conflict: unable to remove repository reference (must force) - container 1234 is using its referenced image',
|
|
577
|
+
status: 1,
|
|
578
|
+
}
|
|
579
|
+
: { success: true, stdout: '' },
|
|
580
|
+
}),
|
|
581
|
+
});
|
|
582
|
+
expect(result.success).toBe(false);
|
|
583
|
+
expect(result.output).toContain(
|
|
584
|
+
'Retained image start-command-resume/box:1 (Error response from daemon: conflict'
|
|
585
|
+
);
|
|
586
|
+
expect(result.error).toBe(
|
|
587
|
+
'1 Docker object(s) retained; nothing was forced.'
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('only lists with --cleanup-dry-run', () => {
|
|
592
|
+
const record = chainRecord();
|
|
593
|
+
const store = storeWith(record);
|
|
594
|
+
const calls = [];
|
|
595
|
+
const result = cleanupExecution(store, record.uuid, {
|
|
596
|
+
dryRun: true,
|
|
597
|
+
runner: fakeDocker(calls, {
|
|
598
|
+
image: gone('image'),
|
|
599
|
+
inspect: gone('container'),
|
|
600
|
+
}),
|
|
601
|
+
});
|
|
602
|
+
expect(result.success).toBe(true);
|
|
603
|
+
expect(calls.some((args) => ['rm', 'rmi'].includes(args[0]))).toBe(false);
|
|
604
|
+
expect(result.output.split('\n')[0]).toBe(
|
|
605
|
+
'Would remove container box-resume-1-resume-2'
|
|
606
|
+
);
|
|
607
|
+
expect(result.output).toContain(
|
|
608
|
+
'Already gone: image start-command-resume/box:1'
|
|
609
|
+
);
|
|
610
|
+
expect(store.get(record.uuid).containerState).toBe(undefined);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('refuses unknown and non-Docker executions', () => {
|
|
614
|
+
const store = storeWith(
|
|
615
|
+
chainRecord({ options: { isolated: 'screen', sessionName: 's' } })
|
|
616
|
+
);
|
|
617
|
+
expect(cleanupExecution(store, 'missing').error).toContain(
|
|
618
|
+
'No execution found'
|
|
619
|
+
);
|
|
620
|
+
expect(cleanupExecution(store, 's').error).toContain(
|
|
621
|
+
'is not a Docker execution'
|
|
622
|
+
);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('runs from the command line with a stub docker', () => {
|
|
626
|
+
if (process.platform === 'win32') {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const appFolder = makeTempDir('app-224-');
|
|
630
|
+
const record = chainRecord();
|
|
631
|
+
new ExecutionStore({ appFolder, useLinks: false }).save(record);
|
|
632
|
+
const bin = makeTempDir('bin-224-');
|
|
633
|
+
const docker = path.join(bin, 'docker');
|
|
634
|
+
fs.writeFileSync(
|
|
635
|
+
docker,
|
|
636
|
+
[
|
|
637
|
+
'#!/bin/sh',
|
|
638
|
+
`echo "$*" >> '${bin}/calls'`,
|
|
639
|
+
'case "$1" in',
|
|
640
|
+
' inspect) echo "no such object: $4" >&2; exit 1;;',
|
|
641
|
+
' rm|rmi) exit 0;;',
|
|
642
|
+
'esac',
|
|
643
|
+
'exit 1',
|
|
644
|
+
'',
|
|
645
|
+
].join('\n')
|
|
646
|
+
);
|
|
647
|
+
fs.chmodSync(docker, 0o755);
|
|
648
|
+
const run = spawnSync(
|
|
649
|
+
process.execPath,
|
|
650
|
+
[path.join(__dirname, '../src/bin/cli.js'), '--cleanup', 'box'],
|
|
651
|
+
{
|
|
652
|
+
encoding: 'utf8',
|
|
653
|
+
env: {
|
|
654
|
+
...process.env,
|
|
655
|
+
START_APP_FOLDER: appFolder,
|
|
656
|
+
START_DOCKER_BIN: docker,
|
|
657
|
+
},
|
|
658
|
+
}
|
|
659
|
+
);
|
|
660
|
+
expect(run.stderr).toBe('');
|
|
661
|
+
expect(run.status).toBe(0);
|
|
662
|
+
expect(run.stdout).toContain('Removed container box-resume-1-resume-2');
|
|
663
|
+
expect(run.stdout).toContain('Removed image start-command-resume/box:1');
|
|
664
|
+
const removals = fs
|
|
665
|
+
.readFileSync(`${bin}/calls`, 'utf8')
|
|
666
|
+
.split('\n')
|
|
667
|
+
.filter((line) => /^(rm|rmi) /.test(line));
|
|
668
|
+
expect(removals).toHaveLength(5);
|
|
669
|
+
expect(removals.some((line) => /\s(-f|--force)\b/.test(line))).toBe(false);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
describe('issue #224 item 5: progress during docker commit', () => {
|
|
674
|
+
it('formats elapsed time in seconds, then minutes', () => {
|
|
675
|
+
expect(formatElapsed(45)).toBe('45s');
|
|
676
|
+
expect(formatElapsed(720)).toBe('12 min');
|
|
677
|
+
expect(formatElapsed(725)).toBe('12 min 5s');
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('writes elapsed-time lines to the log while the commit runs', async () => {
|
|
681
|
+
const logPath = path.join(makeTempDir('log-224-'), 'execution.log');
|
|
682
|
+
const lines = [];
|
|
683
|
+
const result = await commitWithProgress(
|
|
684
|
+
{ command: 'docker', args: ['commit', 'box', 'image'] },
|
|
685
|
+
{
|
|
686
|
+
sessionName: 'box',
|
|
687
|
+
logPath,
|
|
688
|
+
notify: (line) => lines.push(line),
|
|
689
|
+
intervalSeconds: 0.05,
|
|
690
|
+
runAsync: () =>
|
|
691
|
+
new Promise((resolve) =>
|
|
692
|
+
setTimeout(() => resolve({ success: true, stdout: 'sha' }), 400)
|
|
693
|
+
),
|
|
694
|
+
}
|
|
695
|
+
);
|
|
696
|
+
expect(result.stdout).toBe('sha');
|
|
697
|
+
const log = fs.readFileSync(logPath, 'utf8').trim().split('\n');
|
|
698
|
+
expect(log.length).toBeGreaterThan(2);
|
|
699
|
+
expect(log[0]).toBe(
|
|
700
|
+
'[Resume] snapshotting box: 0s elapsed (docker commit reports no progress)'
|
|
701
|
+
);
|
|
702
|
+
expect(log.at(-1)).toBe('[Resume] snapshot of box committed after 0s');
|
|
703
|
+
expect(lines).toEqual(log);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('logs the commit of a real resume', async () => {
|
|
707
|
+
const { store, current } = snapshotRecord();
|
|
708
|
+
const result = await resumeExecution(store, 'box', {
|
|
709
|
+
command: 'true',
|
|
710
|
+
probe: stopped,
|
|
711
|
+
runner: fakeDocker([]),
|
|
712
|
+
snapshotOptions: plentyOfDisk,
|
|
713
|
+
notify: () => {},
|
|
714
|
+
startWatcher: () => true,
|
|
715
|
+
});
|
|
716
|
+
expect(result.success).toBe(true);
|
|
717
|
+
expect(fs.readFileSync(current().logPath, 'utf8')).toContain(
|
|
718
|
+
'[Resume] snapshot of box committed after'
|
|
719
|
+
);
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
describe('issue #224 item 6: --snapshot-size-hint', () => {
|
|
724
|
+
it('skips docker inspect --size', () => {
|
|
725
|
+
const calls = [];
|
|
726
|
+
const result = preflightSnapshot('box', fakeDocker(calls), {
|
|
727
|
+
freeBytes: () => 100 * GiB,
|
|
728
|
+
sizeHint: 4 * GiB,
|
|
729
|
+
});
|
|
730
|
+
expect(calls.some((args) => args.includes('--size'))).toBe(false);
|
|
731
|
+
expect(result.required).toBe(18 * GiB);
|
|
732
|
+
expect(result.message).toBe(
|
|
733
|
+
'[Resume] snapshotting 4.00 GiB (size hint); disk preflight passed (18.00 GiB required).'
|
|
734
|
+
);
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
describe('issue #224 item 7: --snapshot-prepare', () => {
|
|
739
|
+
it('starts, execs, kills and waits the original before the preflight and commit', async () => {
|
|
740
|
+
const { store } = snapshotRecord();
|
|
741
|
+
const calls = [];
|
|
742
|
+
const result = await resumeExecution(store, 'box', {
|
|
743
|
+
command: 'true',
|
|
744
|
+
probe: stopped,
|
|
745
|
+
runner: fakeDocker(calls),
|
|
746
|
+
snapshotOptions: { ...plentyOfDisk, prepare: 'rm -rf /tmp/cache' },
|
|
747
|
+
notify: () => {},
|
|
748
|
+
startWatcher: () => true,
|
|
749
|
+
});
|
|
750
|
+
expect(result.success).toBe(true);
|
|
751
|
+
const order = calls.map((args) => args.slice(0, 2).join(' '));
|
|
752
|
+
const prepare = order.indexOf('start box');
|
|
753
|
+
expect(order.slice(prepare, prepare + 4)).toEqual([
|
|
754
|
+
'start box',
|
|
755
|
+
'exec box',
|
|
756
|
+
'kill box',
|
|
757
|
+
'wait box',
|
|
758
|
+
]);
|
|
759
|
+
expect(calls[order.indexOf('exec box')]).toEqual([
|
|
760
|
+
'exec',
|
|
761
|
+
'box',
|
|
762
|
+
'sh',
|
|
763
|
+
'-c',
|
|
764
|
+
'rm -rf /tmp/cache',
|
|
765
|
+
]);
|
|
766
|
+
expect(prepare).toBeLessThan(
|
|
767
|
+
calls.findIndex((args) => args.includes('--size'))
|
|
768
|
+
);
|
|
769
|
+
expect(order.indexOf('wait box')).toBeLessThan(order.indexOf('commit box'));
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it('still kills the original and snapshots when the command fails', async () => {
|
|
773
|
+
const { store, current } = snapshotRecord();
|
|
774
|
+
const calls = [];
|
|
775
|
+
const result = await resumeExecution(store, 'box', {
|
|
776
|
+
command: 'true',
|
|
777
|
+
probe: stopped,
|
|
778
|
+
runner: fakeDocker(calls, {
|
|
779
|
+
exec: () => ({ success: false, stderr: 'rm: busy', status: 1 }),
|
|
780
|
+
}),
|
|
781
|
+
snapshotOptions: { ...plentyOfDisk, prepare: 'rm -rf /busy' },
|
|
782
|
+
notify: () => {},
|
|
783
|
+
startWatcher: () => true,
|
|
784
|
+
});
|
|
785
|
+
expect(result.success).toBe(true);
|
|
786
|
+
expect(calls.some((args) => args[0] === 'kill')).toBe(true);
|
|
787
|
+
expect(calls.some((args) => args[0] === 'commit')).toBe(true);
|
|
788
|
+
expect(fs.readFileSync(current().logPath, 'utf8')).toContain(
|
|
789
|
+
'[Resume] snapshot prepare failed in box (rm: busy); snapshotting the files as they are'
|
|
790
|
+
);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('never runs for a docker start resume, which commits nothing', async () => {
|
|
794
|
+
const { store } = snapshotRecord({ commandHandoff: true });
|
|
795
|
+
const calls = [];
|
|
796
|
+
const result = await resumeExecution(store, 'box', {
|
|
797
|
+
command: 'true',
|
|
798
|
+
probe: stopped,
|
|
799
|
+
runner: fakeDocker(calls),
|
|
800
|
+
snapshotOptions: { ...plentyOfDisk, prepare: 'rm -rf /tmp/cache' },
|
|
801
|
+
startWatcher: () => true,
|
|
802
|
+
});
|
|
803
|
+
expect(result.success).toBe(true);
|
|
804
|
+
expect(calls.some((args) => args[0] === 'exec')).toBe(false);
|
|
805
|
+
expect(calls.some((args) => args[0] === 'commit')).toBe(false);
|
|
806
|
+
});
|
|
807
|
+
});
|