start-command 0.36.0 → 0.37.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/CHANGELOG.md +8 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/bin/cli.js +5 -0
- package/src/lib/args-parser-queries.js +14 -0
- package/src/lib/args-parser.js +20 -1
- package/src/lib/command-builder.js +3 -0
- package/src/lib/docker-cleanup.js +12 -5
- package/src/lib/docker-command-handoff.js +75 -0
- package/src/lib/docker-labels.js +33 -0
- package/src/lib/docker-runtime-args.js +8 -0
- package/src/lib/docker-snapshot-safety.js +164 -0
- package/src/lib/execution-recovery.js +8 -1
- package/src/lib/execution-resume.js +184 -37
- package/src/lib/failure-handler.js +15 -5
- package/src/lib/isolation.js +20 -4
- package/src/lib/log-sanitizer.js +267 -0
- package/src/lib/log-uploader.js +38 -19
- package/src/lib/query-commands.js +4 -3
- package/src/lib/shell-utils.js +67 -17
- package/src/lib/usage.js +5 -1
- package/test/ci-workflow-invariants.js +15 -14
- package/test/docker-command-handoff.js +107 -0
- package/test/docker-runtime-options.js +4 -0
- package/test/docker-snapshot-safety.js +106 -0
- package/test/execution-resume.js +10 -8
- package/test/failure-handler.js +11 -0
- package/test/javascript-first-ci.js +216 -0
- package/test/log-sanitizer.js +306 -0
- package/test/regression-176.js +7 -0
- package/test/regression-187.js +9 -1
- package/test/regression-191.js +9 -1
- package/test/regression-193.js +11 -1
- package/test/regression-198.js +281 -0
- package/test/regression-199.js +79 -0
- package/test/regression-201.js +25 -0
- package/test/regression-202.js +92 -0
- package/test/regression-91.js +23 -20
- package/test/shared-golden.js +54 -0
- package/test/status-query.js +97 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.37.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 94db203: Add copy-free Docker command resumes with guarded legacy snapshots, persistent
|
|
8
|
+
task labels, wrapper help flags, private sanitized log uploads, and faithful shell
|
|
9
|
+
argument and exit-code handling. Enforce JavaScript-first CI and shared Rust parity.
|
|
10
|
+
|
|
3
11
|
## 0.36.0
|
|
4
12
|
|
|
5
13
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -24,6 +24,8 @@ $ ls -la
|
|
|
24
24
|
$ bun test
|
|
25
25
|
$ git status
|
|
26
26
|
$ --list
|
|
27
|
+
$ --help
|
|
28
|
+
$ -i docker -d --label task.tool=codex -- my-task
|
|
27
29
|
$ --attach <id>
|
|
28
30
|
$ --resume <id> -- <command>
|
|
29
31
|
$ --resume-all
|
|
@@ -34,6 +36,10 @@ See the project-wide [README](../README.md), [docs/USAGE.md](../docs/USAGE.md),
|
|
|
34
36
|
[docs/EXAMPLES.md](../docs/EXAMPLES.md) for the full user-facing guide and
|
|
35
37
|
checked examples.
|
|
36
38
|
|
|
39
|
+
`-h` also prints usage. Labels persist across Docker resumes. New detached
|
|
40
|
+
containers support copy-free command handoff; legacy snapshots require capacity
|
|
41
|
+
preflight. Log uploads are private and sanitized; manual `--no-sanitize` opts out.
|
|
42
|
+
|
|
37
43
|
## Development
|
|
38
44
|
|
|
39
45
|
```bash
|
package/package.json
CHANGED
package/src/bin/cli.js
CHANGED
|
@@ -192,6 +192,10 @@ try {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
const { wrapperOptions, command: parsedCommand } = parsedArgs;
|
|
195
|
+
if (wrapperOptions.help) {
|
|
196
|
+
printUsage();
|
|
197
|
+
process.exit(0);
|
|
198
|
+
}
|
|
195
199
|
|
|
196
200
|
// Query/control modes (--status, --list, --attach, --resume, ...) address an
|
|
197
201
|
// execution that already exists, so they never need a command of their own.
|
|
@@ -479,6 +483,7 @@ async function runWithIsolation(
|
|
|
479
483
|
result = await runIsolated(environment, cmd, {
|
|
480
484
|
...options,
|
|
481
485
|
session: sessionName,
|
|
486
|
+
sessionId,
|
|
482
487
|
image: effectiveImage,
|
|
483
488
|
endpoint: options.endpoint,
|
|
484
489
|
detached: mode === 'detached',
|
|
@@ -26,6 +26,8 @@ function createQueryOptionDefaults() {
|
|
|
26
26
|
list: false, // List all tracked execution records
|
|
27
27
|
running: false, // Restrict --list to executions that are still running
|
|
28
28
|
uploadLog: null, // UUID/session name whose stored log should be uploaded
|
|
29
|
+
noSanitize: false,
|
|
30
|
+
removeOriginal: false,
|
|
29
31
|
outputFormat: null, // Output format (links-notation, json, text)
|
|
30
32
|
stop: null, // UUID/session name to stop gracefully
|
|
31
33
|
terminate: null, // UUID/session name to terminate immediately
|
|
@@ -55,6 +57,12 @@ const IDENTIFIER_OPTIONS = {
|
|
|
55
57
|
* Boolean flags with no argument.
|
|
56
58
|
*/
|
|
57
59
|
const FLAG_OPTIONS = {
|
|
60
|
+
'--no-sanitize': (options) => {
|
|
61
|
+
options.noSanitize = true;
|
|
62
|
+
},
|
|
63
|
+
'--remove-original': (options) => {
|
|
64
|
+
options.removeOriginal = true;
|
|
65
|
+
},
|
|
58
66
|
'--list': (options) => {
|
|
59
67
|
options.list = true;
|
|
60
68
|
},
|
|
@@ -185,6 +193,12 @@ const OUTPUT_FORMAT_MODES = ['--status', '--list', '--resume', '--resume-all'];
|
|
|
185
193
|
* @throws {Error} When the combination is not supported
|
|
186
194
|
*/
|
|
187
195
|
function validateQueryOptions(options) {
|
|
196
|
+
if (options.noSanitize && !hasValue(options.uploadLog)) {
|
|
197
|
+
throw new Error('--no-sanitize is only valid with --upload-log');
|
|
198
|
+
}
|
|
199
|
+
if (options.removeOriginal && !hasValue(options.resume)) {
|
|
200
|
+
throw new Error('--remove-original is only valid with --resume');
|
|
201
|
+
}
|
|
188
202
|
if (hasValue(options.outputFormat)) {
|
|
189
203
|
if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) {
|
|
190
204
|
throw new Error(
|
package/src/lib/args-parser.js
CHANGED
|
@@ -193,6 +193,8 @@ function parseArgs(args) {
|
|
|
193
193
|
volumes: [], // Docker bind mounts/volumes (-v/--volume), applied to docker levels
|
|
194
194
|
mounts: [], // Docker --mount specs, applied to docker levels
|
|
195
195
|
env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels
|
|
196
|
+
labels: [], // Caller attribution, applied to every Docker level
|
|
197
|
+
help: false,
|
|
196
198
|
privileged: false, // Run docker container in privileged mode
|
|
197
199
|
network: null, // First Docker network name (compatibility accessor)
|
|
198
200
|
networks: [], // Ordered Docker network names
|
|
@@ -247,7 +249,9 @@ function parseArgs(args) {
|
|
|
247
249
|
}
|
|
248
250
|
|
|
249
251
|
// Validate options
|
|
250
|
-
|
|
252
|
+
if (!wrapperOptions.help) {
|
|
253
|
+
validateOptions(wrapperOptions);
|
|
254
|
+
}
|
|
251
255
|
|
|
252
256
|
return {
|
|
253
257
|
wrapperOptions,
|
|
@@ -288,6 +292,16 @@ function parseWrapperArgs(args, options) {
|
|
|
288
292
|
*/
|
|
289
293
|
function parseOption(args, index, options) {
|
|
290
294
|
const arg = args[index];
|
|
295
|
+
if (arg === '--help' || arg === '-h') {
|
|
296
|
+
options.help = true;
|
|
297
|
+
return 1;
|
|
298
|
+
}
|
|
299
|
+
if (arg === '--label' || arg.startsWith('--label=')) {
|
|
300
|
+
const label = arg === '--label' ? args[index + 1] : arg.slice(8);
|
|
301
|
+
require('./docker-labels').validateLabel(label);
|
|
302
|
+
options.labels.push(label);
|
|
303
|
+
return arg === '--label' ? 2 : 1;
|
|
304
|
+
}
|
|
291
305
|
|
|
292
306
|
// --isolated, --isolation, or -i
|
|
293
307
|
if (arg === '--isolated' || arg === '--isolation' || arg === '-i') {
|
|
@@ -568,6 +582,11 @@ function parseOption(args, index, options) {
|
|
|
568
582
|
* @throws {Error} If a docker-only option is set without docker isolation
|
|
569
583
|
*/
|
|
570
584
|
function validateDockerRuntimeOptionsRequireDocker(options) {
|
|
585
|
+
if (options.labels?.length > 0 && !options.resume) {
|
|
586
|
+
throw new Error(
|
|
587
|
+
'--label option is only valid with docker isolation or --resume'
|
|
588
|
+
);
|
|
589
|
+
}
|
|
571
590
|
if (options.volumes && options.volumes.length > 0) {
|
|
572
591
|
throw new Error(
|
|
573
592
|
'--volume option is only valid when isolation stack includes docker'
|
|
@@ -106,6 +106,9 @@ function buildNextLevelCommand(options, command) {
|
|
|
106
106
|
for (const envVar of options.env || []) {
|
|
107
107
|
parts.push(`--env "${envVar}"`);
|
|
108
108
|
}
|
|
109
|
+
for (const label of options.labels || []) {
|
|
110
|
+
parts.push(`--label ${require('./shell-utils').quoteShellArg(label)}`);
|
|
111
|
+
}
|
|
109
112
|
if (options.privileged) {
|
|
110
113
|
parts.push('--privileged');
|
|
111
114
|
}
|
|
@@ -83,7 +83,7 @@ function getDockerContainerCleanupInstructions(containerName) {
|
|
|
83
83
|
`Container kept for investigation: ${containerName}`,
|
|
84
84
|
`Re-enter while running: $ --attach ${containerName}`,
|
|
85
85
|
`Continue the stored command: $ --resume ${containerName}`,
|
|
86
|
-
`Run another command
|
|
86
|
+
`Run another command (legacy containers snapshot the filesystem into a new container): $ --resume ${containerName} -- <command>`,
|
|
87
87
|
`Remove when done: docker rm -f ${containerName}`,
|
|
88
88
|
].join('\n');
|
|
89
89
|
}
|
|
@@ -306,7 +306,7 @@ function buildDockerKeptLogSnippet(containerName, quotedLogPath) {
|
|
|
306
306
|
`printf '\\nContainer kept for investigation: %s\\nReason: %s\\n` +
|
|
307
307
|
`Re-enter while running: $ --attach %s\\n` +
|
|
308
308
|
`Continue the stored command: $ --resume %s\\n` +
|
|
309
|
-
`Run another command
|
|
309
|
+
`Run another command (legacy containers snapshot the filesystem into a new container): $ --resume %s -- <command>\\n` +
|
|
310
310
|
`Remove when done: docker rm -f %s\\n' ` +
|
|
311
311
|
`${quotedName} "$__start_command_reason" ` +
|
|
312
312
|
`${quotedName} ${quotedName} ${quotedName} ${quotedName} >> ${quotedLogPath}`
|
|
@@ -361,6 +361,11 @@ function buildDetachedDockerCompletionScript(
|
|
|
361
361
|
watcherOptions = {}
|
|
362
362
|
) {
|
|
363
363
|
const quotedName = shellQuote(containerName);
|
|
364
|
+
const imageCleanup =
|
|
365
|
+
require('./docker-snapshot-safety').snapshotImageCleanupSnippet(
|
|
366
|
+
containerName,
|
|
367
|
+
logPath ? `>> ${shellQuote(logPath)} 2>&1` : '>/dev/null 2>&1'
|
|
368
|
+
);
|
|
364
369
|
// The container's cgroup disappears with it, so its memory counters are
|
|
365
370
|
// sampled while it runs (issue #182).
|
|
366
371
|
const cpuMonitor = require('./cpu-penalty-monitor');
|
|
@@ -389,7 +394,7 @@ function buildDetachedDockerCompletionScript(
|
|
|
389
394
|
quotedLogPath
|
|
390
395
|
);
|
|
391
396
|
const memory = buildCgroupMemoryLogSnippet(quotedLogPath);
|
|
392
|
-
const remove =
|
|
397
|
+
const remove = `${imageCleanup.capture}; if docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1; then ${imageCleanup.remove}; fi; ${removalNote}; ${memory}`;
|
|
393
398
|
const keep = `${postMortem}; ${memory}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
|
|
394
399
|
|
|
395
400
|
const since = watcherOptions.since
|
|
@@ -438,13 +443,15 @@ function buildDetachedDockerCompletionScript(
|
|
|
438
443
|
parts.push(buildCgroupSamplerStopSnippet());
|
|
439
444
|
parts.push(buildDockerStateSnippet(containerName));
|
|
440
445
|
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
441
|
-
exited.push(
|
|
446
|
+
exited.push(
|
|
447
|
+
`${imageCleanup.capture}; if docker rm -f ${quotedName} >/dev/null 2>&1; then ${imageCleanup.remove}; fi`
|
|
448
|
+
);
|
|
442
449
|
} else if (
|
|
443
450
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
|
|
444
451
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
|
|
445
452
|
) {
|
|
446
453
|
exited.push(
|
|
447
|
-
`if ${buildSuccessfulNonOomCondition()}; then docker rm -f ${quotedName} >/dev/null 2>&1
|
|
454
|
+
`if ${buildSuccessfulNonOomCondition()}; then ${imageCleanup.capture}; if docker rm -f ${quotedName} >/dev/null 2>&1; then ${imageCleanup.remove}; fi; fi`
|
|
448
455
|
);
|
|
449
456
|
}
|
|
450
457
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Copy only a command script into a stopped container, never its writable layer. */
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
6
|
+
|
|
7
|
+
function commandHandoffPath(sessionName) {
|
|
8
|
+
return `/.start-command-resume-${String(sessionName).replace(/[^A-Za-z0-9_.-]/g, '_')}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function buildCommandHandoffArgs(mainArgs, markerPath) {
|
|
12
|
+
return [
|
|
13
|
+
'sh',
|
|
14
|
+
'-c',
|
|
15
|
+
'p=$1; shift; if [ -f "$p" ]; then exec sh "$p"; fi; exec "$@"',
|
|
16
|
+
'start-command-handoff',
|
|
17
|
+
markerPath,
|
|
18
|
+
...mainArgs,
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildCommandHandoffScript(command, options = {}) {
|
|
23
|
+
const {
|
|
24
|
+
isInteractiveShellCommand,
|
|
25
|
+
isShellInvocationWithArgs,
|
|
26
|
+
buildShellWithArgsCmdArgs,
|
|
27
|
+
toShellWords,
|
|
28
|
+
} = require('./shell-utils');
|
|
29
|
+
if (
|
|
30
|
+
!options.keepAlive &&
|
|
31
|
+
(isInteractiveShellCommand(command) || isShellInvocationWithArgs(command))
|
|
32
|
+
) {
|
|
33
|
+
const args = isShellInvocationWithArgs(command)
|
|
34
|
+
? buildShellWithArgsCmdArgs(command)
|
|
35
|
+
: toShellWords(command);
|
|
36
|
+
return `exec ${args.map(shellQuote).join(' ')}\n`;
|
|
37
|
+
}
|
|
38
|
+
if (!options.shell || options.shell === 'auto') {
|
|
39
|
+
const effective = options.keepAlive
|
|
40
|
+
? `${command}; exec "$__START_COMMAND_HANDOFF_SHELL"`
|
|
41
|
+
: command;
|
|
42
|
+
return `__START_COMMAND_HANDOFF_SHELL=$(command -v bash || command -v zsh || command -v sh); export __START_COMMAND_HANDOFF_SHELL\ncase "$__START_COMMAND_HANDOFF_SHELL" in */bash|bash|*/zsh|zsh) exec "$__START_COMMAND_HANDOFF_SHELL" -i -c ${shellQuote(effective)};; *) exec "$__START_COMMAND_HANDOFF_SHELL" -c ${shellQuote(effective)};; esac\n`;
|
|
43
|
+
}
|
|
44
|
+
const shell = options.shell;
|
|
45
|
+
const interactive = /(?:^|\/)(?:bash|zsh)$/.test(shell) ? ' -i' : '';
|
|
46
|
+
const effective = options.keepAlive
|
|
47
|
+
? `${command}; exec ${shellQuote(shell)}`
|
|
48
|
+
: command;
|
|
49
|
+
return `exec ${shellQuote(shell)}${interactive} -c ${shellQuote(effective)}\n`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeCommandHandoff(containerName, command, runner, options = {}) {
|
|
53
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-handoff-'));
|
|
54
|
+
const file = path.join(dir, 'command');
|
|
55
|
+
try {
|
|
56
|
+
fs.writeFileSync(file, buildCommandHandoffScript(command, options), {
|
|
57
|
+
mode: 0o644,
|
|
58
|
+
});
|
|
59
|
+
fs.chmodSync(file, 0o644);
|
|
60
|
+
return runner(require('./docker-cleanup').getDockerCommand(), [
|
|
61
|
+
'cp',
|
|
62
|
+
file,
|
|
63
|
+
`${containerName}:${commandHandoffPath(containerName)}`,
|
|
64
|
+
]);
|
|
65
|
+
} finally {
|
|
66
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
commandHandoffPath,
|
|
72
|
+
buildCommandHandoffArgs,
|
|
73
|
+
buildCommandHandoffScript,
|
|
74
|
+
writeCommandHandoff,
|
|
75
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Docker attribution shared by initial launches and snapshot resumes (#199). */
|
|
2
|
+
function validateLabel(label) {
|
|
3
|
+
if (typeof label !== 'string' || !/^[^=\s]+=[\s\S]*$/.test(label)) {
|
|
4
|
+
throw new Error('--label requires KEY=VALUE with a nonempty key');
|
|
5
|
+
}
|
|
6
|
+
if (label.startsWith('start-command.')) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
'start-command.* labels are reserved for execution attribution'
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function dockerLabels(options = {}) {
|
|
14
|
+
const labels = [...(options.labels || [])];
|
|
15
|
+
const session = options.containerName || options.session;
|
|
16
|
+
if (session) {
|
|
17
|
+
const inferredRoot =
|
|
18
|
+
Number(options.resumeCount) > 0
|
|
19
|
+
? session.replace(/(?:-resume-\d+)+$/, '')
|
|
20
|
+
: session;
|
|
21
|
+
labels.push(`start-command.session=${session}`);
|
|
22
|
+
labels.push(
|
|
23
|
+
`start-command.root-session=${options.rootSession || inferredRoot}`
|
|
24
|
+
);
|
|
25
|
+
labels.push(`start-command.resume-count=${options.resumeCount || 0}`);
|
|
26
|
+
}
|
|
27
|
+
if (options.sessionId || options.uuid) {
|
|
28
|
+
labels.push(`start-command.uuid=${options.sessionId || options.uuid}`);
|
|
29
|
+
}
|
|
30
|
+
return labels;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { validateLabel, dockerLabels };
|
|
@@ -42,6 +42,9 @@ function buildDockerRuntimeArgs(options = {}) {
|
|
|
42
42
|
for (const envVar of options.env || []) {
|
|
43
43
|
args.push('-e', envVar);
|
|
44
44
|
}
|
|
45
|
+
for (const label of require('./docker-labels').dockerLabels(options)) {
|
|
46
|
+
args.push('--label', label);
|
|
47
|
+
}
|
|
45
48
|
for (const volume of options.volumes || []) {
|
|
46
49
|
args.push('-v', volume);
|
|
47
50
|
}
|
|
@@ -69,6 +72,9 @@ function buildDockerRuntimeArgs(options = {}) {
|
|
|
69
72
|
*/
|
|
70
73
|
function buildDockerRuntimeStatusLines(options = {}) {
|
|
71
74
|
const lines = [];
|
|
75
|
+
if (options.labels && options.labels.length > 0) {
|
|
76
|
+
lines.push(`[Isolation] Labels: ${options.labels.join(', ')}`);
|
|
77
|
+
}
|
|
72
78
|
if (options.volumes && options.volumes.length > 0) {
|
|
73
79
|
lines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`);
|
|
74
80
|
}
|
|
@@ -129,6 +135,8 @@ function buildDockerRuntimeMetadata(options = {}) {
|
|
|
129
135
|
options.volumes && options.volumes.length > 0 ? options.volumes : null,
|
|
130
136
|
mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
|
|
131
137
|
env: options.env && options.env.length > 0 ? options.env : null,
|
|
138
|
+
labels: options.labels && options.labels.length > 0 ? options.labels : null,
|
|
139
|
+
commandHandoff: options.detached ? true : null,
|
|
132
140
|
privileged: options.privileged || null,
|
|
133
141
|
network: networks[0] || null,
|
|
134
142
|
networks: networks.length > 0 ? networks : null,
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** Serialize costly snapshot resumes and check every image-store filesystem. */
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { LockManager } = require('./store-lock');
|
|
6
|
+
|
|
7
|
+
const GiB = 1024 ** 3;
|
|
8
|
+
const SNAPSHOT_RESERVE_BYTES = 10 * GiB;
|
|
9
|
+
const SNAPSHOT_IMAGE_LABEL = 'start-command.snapshot-image';
|
|
10
|
+
|
|
11
|
+
function acquireSnapshotLock(options = {}) {
|
|
12
|
+
const lockPath =
|
|
13
|
+
options.lockPath ||
|
|
14
|
+
process.env.START_SNAPSHOT_LOCK ||
|
|
15
|
+
path.join(
|
|
16
|
+
process.platform === 'win32' ? os.tmpdir() : '/tmp',
|
|
17
|
+
'start-command-docker-snapshot.lock'
|
|
18
|
+
);
|
|
19
|
+
const lock = new LockManager(lockPath);
|
|
20
|
+
// The ordinary store lock is held only for a brief write. A snapshot can
|
|
21
|
+
// legitimately take hours, so never reclaim this lock merely for its age.
|
|
22
|
+
lock.isLockStale = (data) => {
|
|
23
|
+
if (
|
|
24
|
+
!data ||
|
|
25
|
+
data.hostname !== os.hostname() ||
|
|
26
|
+
!Number.isInteger(data.pid) ||
|
|
27
|
+
data.pid <= 0
|
|
28
|
+
) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
process.kill(data.pid, 0);
|
|
33
|
+
return false;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return error.code === 'ESRCH';
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
if (!lock.acquire(1000)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'A Docker snapshot resume is in progress on this host. Retry after it finishes.'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return lock;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function containerdRoot(config = '') {
|
|
47
|
+
// Only the root-level TOML key configures containerd's storage root.
|
|
48
|
+
const topLevel = config.split(/^\s*\[/m)[0];
|
|
49
|
+
return (
|
|
50
|
+
topLevel.match(/^\s*root\s*=\s*["']([^"']+)["']/m)?.[1] ||
|
|
51
|
+
'/var/lib/containerd'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function defaultContainerdRoot() {
|
|
56
|
+
if (process.env.START_CONTAINERD_ROOT) {
|
|
57
|
+
return process.env.START_CONTAINERD_ROOT;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return containerdRoot(
|
|
61
|
+
fs.readFileSync('/etc/containerd/config.toml', 'utf8')
|
|
62
|
+
);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.code === 'ENOENT') {
|
|
65
|
+
return containerdRoot();
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readFreeBytes(root) {
|
|
72
|
+
const stats = fs.statfsSync(root, { bigint: true });
|
|
73
|
+
return Number(stats.bavail * stats.bsize);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function preflightSnapshot(containerName, runner, options = {}) {
|
|
77
|
+
const docker = require('./docker-cleanup').getDockerCommand();
|
|
78
|
+
const sizeResult = runner(docker, [
|
|
79
|
+
'inspect',
|
|
80
|
+
'--size',
|
|
81
|
+
'--format',
|
|
82
|
+
'{{.SizeRw}}',
|
|
83
|
+
containerName,
|
|
84
|
+
]);
|
|
85
|
+
const size = Number(String(sizeResult.stdout || '').trim());
|
|
86
|
+
if (
|
|
87
|
+
!sizeResult.success ||
|
|
88
|
+
!/^\d+$/.test(String(sizeResult.stdout || '').trim()) ||
|
|
89
|
+
!Number.isSafeInteger(size) ||
|
|
90
|
+
size < 0
|
|
91
|
+
) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
'Cannot determine the writable layer size; refusing Docker snapshot resume.'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const infoResult = runner(docker, ['info', '--format', '{{json .}}']);
|
|
97
|
+
let info;
|
|
98
|
+
try {
|
|
99
|
+
info = JSON.parse(infoResult.stdout);
|
|
100
|
+
} catch {
|
|
101
|
+
/* Refuse unknown storage. */
|
|
102
|
+
}
|
|
103
|
+
if (!infoResult.success || !info?.DockerRootDir) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
'Cannot determine Docker data root; refusing Docker snapshot resume.'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const roots = [info.DockerRootDir];
|
|
109
|
+
if (
|
|
110
|
+
JSON.stringify(info.DriverStatus || []).includes(
|
|
111
|
+
'io.containerd.snapshotter'
|
|
112
|
+
)
|
|
113
|
+
) {
|
|
114
|
+
roots.push(options.containerdRoot || defaultContainerdRoot());
|
|
115
|
+
}
|
|
116
|
+
const required = 2 * size + (options.reserveBytes ?? SNAPSHOT_RESERVE_BYTES);
|
|
117
|
+
const free = options.freeBytes || readFreeBytes;
|
|
118
|
+
for (const root of [...new Set(roots)]) {
|
|
119
|
+
let available;
|
|
120
|
+
try {
|
|
121
|
+
available = free(root);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Cannot check free disk at ${root}: ${error.message}; refusing Docker snapshot resume.`,
|
|
125
|
+
{ cause: error }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (!Number.isFinite(available) || available < required) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Insufficient disk at ${root}: need ${(required / GiB).toFixed(2)} GiB (2 × writable layer plus reserve), available ${(available / GiB).toFixed(2)} GiB. Docker snapshot resume refused.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
size,
|
|
136
|
+
required,
|
|
137
|
+
roots,
|
|
138
|
+
message: `[Resume] snapshotting ${(size / GiB).toFixed(2)} GiB; disk preflight passed (${(required / GiB).toFixed(2)} GiB required).`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function snapshotImageCleanupSnippet(
|
|
143
|
+
containerName,
|
|
144
|
+
redirection = '>/dev/null 2>&1'
|
|
145
|
+
) {
|
|
146
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
147
|
+
const name = shellQuote(containerName);
|
|
148
|
+
const format = shellQuote(
|
|
149
|
+
`{{index .Config.Labels "${SNAPSHOT_IMAGE_LABEL}"}}`
|
|
150
|
+
);
|
|
151
|
+
return {
|
|
152
|
+
capture: `__start_snapshot_image=$(docker inspect -f ${format} ${name} 2>/dev/null || true)`,
|
|
153
|
+
remove: `case "$__start_snapshot_image" in start-command-resume/*) docker rmi "$__start_snapshot_image" ${redirection} || true;; esac`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = {
|
|
158
|
+
acquireSnapshotLock,
|
|
159
|
+
containerdRoot,
|
|
160
|
+
preflightSnapshot,
|
|
161
|
+
SNAPSHOT_RESERVE_BYTES,
|
|
162
|
+
SNAPSHOT_IMAGE_LABEL,
|
|
163
|
+
snapshotImageCleanupSnippet,
|
|
164
|
+
};
|
|
@@ -357,7 +357,14 @@ function recoverKilledExecution(params = {}) {
|
|
|
357
357
|
if (opts.recoveryCommand) {
|
|
358
358
|
let copied;
|
|
359
359
|
try {
|
|
360
|
-
copied =
|
|
360
|
+
copied = opts.commandHandoff
|
|
361
|
+
? require('./docker-command-handoff').writeCommandHandoff(
|
|
362
|
+
containerName,
|
|
363
|
+
`export ${RECOVERY_ATTEMPT_ENV}=${attempt}; ${opts.recoveryCommand}`,
|
|
364
|
+
runner,
|
|
365
|
+
opts
|
|
366
|
+
)
|
|
367
|
+
: writeRecoveryMarker(containerName, attempt, runner);
|
|
361
368
|
} catch (err) {
|
|
362
369
|
copied = { success: false, error: err.message };
|
|
363
370
|
}
|