start-command 0.37.8 → 0.38.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 +16 -0
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/bin/cli.js +66 -26
- package/src/lib/args-parser-queries.js +90 -9
- package/src/lib/args-parser.js +20 -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-command-handoff.js +34 -6
- package/src/lib/docker-post-mortem.js +2 -0
- package/src/lib/docker-runtime-args.js +7 -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 +21 -9
- package/src/lib/execution-chain.js +182 -0
- package/src/lib/execution-cleanup.js +193 -0
- package/src/lib/execution-control.js +2 -1
- package/src/lib/execution-env.js +120 -0
- package/src/lib/execution-identity.js +180 -0
- package/src/lib/execution-recovery.js +19 -0
- package/src/lib/execution-resume.js +77 -6
- package/src/lib/execution-store.js +63 -22
- package/src/lib/isolation.js +25 -3
- package/src/lib/launch-identity-options.js +81 -0
- package/src/lib/launch-identity.js +411 -0
- package/src/lib/log-uploader.js +15 -1
- package/src/lib/query-commands.js +34 -6
- package/src/lib/screen-isolation.js +11 -2
- package/src/lib/snapshot-resume-options.js +174 -0
- package/src/lib/spawn-helpers.js +33 -16
- package/src/lib/status-formatter.js +64 -35
- package/src/lib/status-links-notation.js +97 -0
- package/src/lib/store-lock.js +3 -0
- package/src/lib/usage.js +15 -1
- package/test/args-parser-attach-resume.js +1 -1
- package/test/args-parser-control.js +2 -2
- package/test/regression-224.js +807 -0
- package/test/regression-225.js +415 -0
- package/test/regression-226.js +81 -0
- package/test/regression-229.js +267 -0
- package/test/regression-230.js +234 -0
- package/test/regression-231.js +298 -0
- package/test/shared-golden.js +35 -0
|
@@ -63,6 +63,8 @@ const WATCHER_LOST_CONTAINER = 'watcher-lost-container';
|
|
|
63
63
|
* @param {boolean|string|null} [options.running] - `.State.Running`
|
|
64
64
|
* @param {string|null} [options.cgroupMemory] - Last cgroup v2 sample of the
|
|
65
65
|
* watcher, `<memory.max> <memory.peak> <oom> <oom_kill>` (issue #182)
|
|
66
|
+
* @param {boolean|string|null} [options.containerRemoved] - The watcher's own
|
|
67
|
+
* `docker rm -f` removed the container (issue #225)
|
|
66
68
|
* @returns {{updated: boolean, reason?: string, record?: Object}} Outcome
|
|
67
69
|
*/
|
|
68
70
|
function finalizeDetachedExecution(options = {}) {
|
|
@@ -96,9 +98,23 @@ function finalizeDetachedExecution(options = {}) {
|
|
|
96
98
|
) {
|
|
97
99
|
return { updated: false, reason: 'stale-attempt' };
|
|
98
100
|
}
|
|
101
|
+
const containerRemoved = normalizeBoolean(options.containerRemoved) === true;
|
|
99
102
|
if (record.status === 'executed' && record.endTime) {
|
|
100
103
|
// Already finalized (e.g. a `--status` query got there first, or the
|
|
101
|
-
// watcher ran twice after a resume).
|
|
104
|
+
// watcher ran twice after a resume). Only the removal can still be new.
|
|
105
|
+
if (containerRemoved && record.containerState !== 'removed') {
|
|
106
|
+
try {
|
|
107
|
+
const marked = store.markContainerRemoved(
|
|
108
|
+
record.uuid,
|
|
109
|
+
new Date().toISOString()
|
|
110
|
+
);
|
|
111
|
+
if (marked) {
|
|
112
|
+
return { updated: true, reason: 'container-removed', record: marked };
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Bookkeeping only: see the exit-0 contract of `main`.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
102
118
|
return { updated: false, reason: 'already-final', record };
|
|
103
119
|
}
|
|
104
120
|
|
|
@@ -171,6 +187,11 @@ function finalizeDetachedExecution(options = {}) {
|
|
|
171
187
|
if (containerError) {
|
|
172
188
|
record.options = { ...(record.options || {}), containerError };
|
|
173
189
|
}
|
|
190
|
+
if (containerRemoved) {
|
|
191
|
+
// The cleanup policy removed the container: say so, and keep its id as
|
|
192
|
+
// history (issue #225).
|
|
193
|
+
require('./container-removal').markContainerRemoved(record);
|
|
194
|
+
}
|
|
174
195
|
|
|
175
196
|
const {
|
|
176
197
|
appendLifecycle,
|
|
@@ -291,7 +312,9 @@ function reconcileFinalizedRecord(store, record) {
|
|
|
291
312
|
*/
|
|
292
313
|
function buildDetachedFinalizeSnippet(executionId, attemptNumber = null) {
|
|
293
314
|
const v = SHELL_VARS;
|
|
315
|
+
const { CONTAINER_REMOVED_ENV } = require('./container-removal');
|
|
294
316
|
return (
|
|
317
|
+
`${CONTAINER_REMOVED_ENV}="$${v.removed}" ` +
|
|
295
318
|
`${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
|
|
296
319
|
`${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
|
|
297
320
|
`"$${v.started}" "$${v.finished}" "$${v.error}" "$${v.running}" ` +
|
|
@@ -301,7 +324,9 @@ function buildDetachedFinalizeSnippet(executionId, attemptNumber = null) {
|
|
|
301
324
|
|
|
302
325
|
/**
|
|
303
326
|
* Entry point used by the detached watcher:
|
|
304
|
-
* <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error> <running> <cgroup>
|
|
327
|
+
* <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error> <running> <cgroup> [attempt]
|
|
328
|
+
* with `START_INTERNAL_CONTAINER_REMOVED=true` when the watcher removed the
|
|
329
|
+
* container (issue #225).
|
|
305
330
|
* Always exits 0 — a bookkeeping failure must never turn into a visible error
|
|
306
331
|
* in a log the user is reading for the command's own output.
|
|
307
332
|
* @param {string[]} argv - Positional arguments
|
|
@@ -340,6 +365,8 @@ function main(argv) {
|
|
|
340
365
|
running,
|
|
341
366
|
cgroupMemory,
|
|
342
367
|
attemptNumber: attemptNumber || null,
|
|
368
|
+
containerRemoved:
|
|
369
|
+
process.env[require('./container-removal').CONTAINER_REMOVED_ENV],
|
|
343
370
|
});
|
|
344
371
|
} catch {
|
|
345
372
|
// Deliberately silent: see the exit-0 contract above.
|
|
@@ -381,7 +381,10 @@ function buildDetachedDockerCompletionScript(
|
|
|
381
381
|
}
|
|
382
382
|
// Everything that assumes the container has exited: cleanup, footer and
|
|
383
383
|
// finalization. Guarded as a whole by `.State.Running` below.
|
|
384
|
-
|
|
384
|
+
// `removed` tells the finalizer whether the cleanup policy removed the
|
|
385
|
+
// container (issue #225).
|
|
386
|
+
const removed = SHELL_VARS.removed;
|
|
387
|
+
const exited = [`${removed}=false`];
|
|
385
388
|
|
|
386
389
|
if (logPath) {
|
|
387
390
|
const quotedLogPath = shellQuote(logPath);
|
|
@@ -394,7 +397,7 @@ function buildDetachedDockerCompletionScript(
|
|
|
394
397
|
quotedLogPath
|
|
395
398
|
);
|
|
396
399
|
const memory = buildCgroupMemoryLogSnippet(quotedLogPath);
|
|
397
|
-
const remove = `${imageCleanup.capture}; if docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1; then ${imageCleanup.remove}; fi; ${removalNote}; ${memory}`;
|
|
400
|
+
const remove = `${imageCleanup.capture}; if docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1; then ${removed}=true; ${imageCleanup.remove}; fi; ${removalNote}; ${memory}`;
|
|
398
401
|
const keep = `${postMortem}; ${memory}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
|
|
399
402
|
|
|
400
403
|
const since = watcherOptions.since
|
|
@@ -444,14 +447,14 @@ function buildDetachedDockerCompletionScript(
|
|
|
444
447
|
parts.push(buildDockerStateSnippet(containerName));
|
|
445
448
|
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
446
449
|
exited.push(
|
|
447
|
-
`${imageCleanup.capture}; if docker rm -f ${quotedName} >/dev/null 2>&1; then ${imageCleanup.remove}; fi`
|
|
450
|
+
`${imageCleanup.capture}; if docker rm -f ${quotedName} >/dev/null 2>&1; then ${removed}=true; ${imageCleanup.remove}; fi`
|
|
448
451
|
);
|
|
449
452
|
} else if (
|
|
450
453
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
|
|
451
454
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
|
|
452
455
|
) {
|
|
453
456
|
exited.push(
|
|
454
|
-
`if ${buildSuccessfulNonOomCondition()}; then ${imageCleanup.capture}; if docker rm -f ${quotedName} >/dev/null 2>&1; then ${imageCleanup.remove}; fi; fi`
|
|
457
|
+
`if ${buildSuccessfulNonOomCondition()}; then ${imageCleanup.capture}; if docker rm -f ${quotedName} >/dev/null 2>&1; then ${removed}=true; ${imageCleanup.remove}; fi; fi`
|
|
455
458
|
);
|
|
456
459
|
}
|
|
457
460
|
}
|
|
@@ -12,7 +12,9 @@ function buildCommandHandoffArgs(mainArgs, markerPath) {
|
|
|
12
12
|
return [
|
|
13
13
|
'sh',
|
|
14
14
|
'-c',
|
|
15
|
-
|
|
15
|
+
// `$p.env` refreshes the START_* variables a `docker start` cannot
|
|
16
|
+
// change (issue #230) before either command runs.
|
|
17
|
+
'p=$1; shift; if [ -f "$p.env" ]; then . "$p.env"; fi; if [ -f "$p" ]; then exec sh "$p"; fi; exec "$@"',
|
|
16
18
|
'start-command-handoff',
|
|
17
19
|
markerPath,
|
|
18
20
|
...mainArgs,
|
|
@@ -49,27 +51,53 @@ function buildCommandHandoffScript(command, options = {}) {
|
|
|
49
51
|
return `exec ${shellQuote(shell)}${interactive} -c ${shellQuote(effective)}\n`;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
function
|
|
54
|
+
function copyIntoContainer(containerName, target, content, runner) {
|
|
53
55
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-handoff-'));
|
|
54
56
|
const file = path.join(dir, 'command');
|
|
55
57
|
try {
|
|
56
|
-
fs.writeFileSync(file,
|
|
57
|
-
mode: 0o644,
|
|
58
|
-
});
|
|
58
|
+
fs.writeFileSync(file, content, { mode: 0o644 });
|
|
59
59
|
fs.chmodSync(file, 0o644);
|
|
60
60
|
return runner(require('./docker-cleanup').getDockerCommand(), [
|
|
61
61
|
'cp',
|
|
62
62
|
file,
|
|
63
|
-
`${containerName}:${
|
|
63
|
+
`${containerName}:${target}`,
|
|
64
64
|
]);
|
|
65
65
|
} finally {
|
|
66
66
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
function writeCommandHandoff(containerName, command, runner, options = {}) {
|
|
71
|
+
return copyIntoContainer(
|
|
72
|
+
containerName,
|
|
73
|
+
commandHandoffPath(containerName),
|
|
74
|
+
buildCommandHandoffScript(command, options),
|
|
75
|
+
runner
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Copy the START_* variables of the next attempt into a stopped container
|
|
81
|
+
* that was created with the handoff selector (issue #230).
|
|
82
|
+
* @param {string} containerName - Container name
|
|
83
|
+
* @param {object} env - Variables from `buildExecutionEnv`
|
|
84
|
+
* @param {Function} runner - Command runner
|
|
85
|
+
* @returns {object} Runner result
|
|
86
|
+
*/
|
|
87
|
+
function writeExecutionEnvHandoff(containerName, env, runner) {
|
|
88
|
+
const statement = require('./execution-env').buildExecutionEnvExport(env);
|
|
89
|
+
return copyIntoContainer(
|
|
90
|
+
containerName,
|
|
91
|
+
`${commandHandoffPath(containerName)}.env`,
|
|
92
|
+
statement ? `${statement}\n` : '',
|
|
93
|
+
runner
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
module.exports = {
|
|
71
98
|
commandHandoffPath,
|
|
72
99
|
buildCommandHandoffArgs,
|
|
73
100
|
buildCommandHandoffScript,
|
|
74
101
|
writeCommandHandoff,
|
|
102
|
+
writeExecutionEnvHandoff,
|
|
75
103
|
};
|
|
@@ -139,6 +139,8 @@ const SHELL_VARS = {
|
|
|
139
139
|
exitText: '__start_command_exit_text',
|
|
140
140
|
lifetime: '__start_command_lifetime',
|
|
141
141
|
running: '__start_command_running',
|
|
142
|
+
/** `true` once the watcher's own `docker rm -f` succeeded (issue #225). */
|
|
143
|
+
removed: '__start_command_removed',
|
|
142
144
|
};
|
|
143
145
|
|
|
144
146
|
/** `docker inspect -f` template collecting every post-mortem fact at once. */
|
|
@@ -42,6 +42,13 @@ function buildDockerRuntimeArgs(options = {}) {
|
|
|
42
42
|
for (const envVar of options.env || []) {
|
|
43
43
|
args.push('-e', envVar);
|
|
44
44
|
}
|
|
45
|
+
// Execution identity (issue #230); last, so a user `--env` cannot spoof it.
|
|
46
|
+
const executionEnv = require('./execution-env');
|
|
47
|
+
for (const envVar of executionEnv.executionEnvAssignments(
|
|
48
|
+
executionEnv.executionEnvFromOptions(options)
|
|
49
|
+
)) {
|
|
50
|
+
args.push('-e', envVar);
|
|
51
|
+
}
|
|
45
52
|
for (const label of require('./docker-labels').dockerLabels(options)) {
|
|
46
53
|
args.push('--label', label);
|
|
47
54
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Steps around the `docker commit` of a snapshot resume (issue #224):
|
|
3
|
+
*
|
|
4
|
+
* - `--snapshot-prepare <command>` runs in the stopped original before the
|
|
5
|
+
* commit: `docker start`, `docker exec ... sh -c <command>`, and always a
|
|
6
|
+
* `docker kill` afterwards. Starting the original runs its own command
|
|
7
|
+
* again, so this is only for containers whose start is known to be safe.
|
|
8
|
+
* - Progress lines while `docker commit` runs, which prints nothing itself.
|
|
9
|
+
* - `--remove-original-after <seconds>`: remove the stopped original only if
|
|
10
|
+
* the replacement is still running after the delay.
|
|
11
|
+
*/
|
|
12
|
+
const { spawn } = require('child_process');
|
|
13
|
+
const { setInterval, clearInterval, setTimeout } = globalThis;
|
|
14
|
+
const { appendLogFile } = require('./isolation-log-utils');
|
|
15
|
+
const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
|
|
16
|
+
|
|
17
|
+
const DEFAULT_PROGRESS_INTERVAL_SECONDS = 60;
|
|
18
|
+
/** Upper bound for one `--snapshot-prepare`; the original is killed after. */
|
|
19
|
+
const DEFAULT_PREPARE_TIMEOUT_SECONDS = 600;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Format a duration the way the progress lines print it: "45s", "12 min".
|
|
23
|
+
* @param {number} seconds - Elapsed seconds
|
|
24
|
+
* @returns {string} Human-readable duration
|
|
25
|
+
*/
|
|
26
|
+
function formatElapsed(seconds) {
|
|
27
|
+
const whole = Math.max(0, Math.floor(seconds));
|
|
28
|
+
if (whole < 60) {
|
|
29
|
+
return `${whole}s`;
|
|
30
|
+
}
|
|
31
|
+
const minutes = Math.floor(whole / 60);
|
|
32
|
+
const rest = whole % 60;
|
|
33
|
+
return rest ? `${minutes} min ${rest}s` : `${minutes} min`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function positiveSeconds(value, fallback) {
|
|
37
|
+
const seconds = Number(value);
|
|
38
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function progressIntervalSeconds(env = process.env) {
|
|
42
|
+
return positiveSeconds(
|
|
43
|
+
env.START_SNAPSHOT_PROGRESS_INTERVAL,
|
|
44
|
+
DEFAULT_PROGRESS_INTERVAL_SECONDS
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Report one line to the execution log and through `notify`. */
|
|
49
|
+
function report(context, line) {
|
|
50
|
+
if (context.logPath) {
|
|
51
|
+
appendLogFile(context.logPath, `${line}\n`);
|
|
52
|
+
}
|
|
53
|
+
context.notify(line);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run a command without blocking the event loop.
|
|
58
|
+
* @returns {Promise<{success, stdout, stderr, status, error}>}
|
|
59
|
+
*/
|
|
60
|
+
function runCommandAsync(command, args) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const options = { stdio: ['ignore', 'pipe', 'pipe'] };
|
|
63
|
+
const child = spawn(
|
|
64
|
+
command,
|
|
65
|
+
args,
|
|
66
|
+
command === getDockerCommand() ? getDockerSpawnOptions(options) : options
|
|
67
|
+
);
|
|
68
|
+
let stdout = '';
|
|
69
|
+
let stderr = '';
|
|
70
|
+
child.stdout.on('data', (chunk) => {
|
|
71
|
+
stdout += chunk;
|
|
72
|
+
});
|
|
73
|
+
child.stderr.on('data', (chunk) => {
|
|
74
|
+
stderr += chunk;
|
|
75
|
+
});
|
|
76
|
+
child.on('error', (error) => {
|
|
77
|
+
resolve({
|
|
78
|
+
success: false,
|
|
79
|
+
stdout,
|
|
80
|
+
stderr,
|
|
81
|
+
status: null,
|
|
82
|
+
error: error.message,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
child.on('close', (status) => {
|
|
86
|
+
resolve({ success: status === 0, stdout, stderr, status, error: null });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run `docker commit` and report the elapsed time every interval.
|
|
93
|
+
* @param {{command: string, args: string[]}} step - The commit step
|
|
94
|
+
* @param {object} context - {sessionName, logPath, notify, runAsync,
|
|
95
|
+
* intervalSeconds}
|
|
96
|
+
* @returns {Promise<object>} The runner result
|
|
97
|
+
*/
|
|
98
|
+
async function commitWithProgress(step, context) {
|
|
99
|
+
const started = Date.now();
|
|
100
|
+
const intervalSeconds = context.intervalSeconds ?? progressIntervalSeconds();
|
|
101
|
+
const timer = setInterval(() => {
|
|
102
|
+
const elapsed = formatElapsed((Date.now() - started) / 1000);
|
|
103
|
+
report(
|
|
104
|
+
context,
|
|
105
|
+
`[Resume] snapshotting ${context.sessionName}: ${elapsed} elapsed (docker commit reports no progress)`
|
|
106
|
+
);
|
|
107
|
+
}, intervalSeconds * 1000);
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = await context.runAsync(step.command, step.args);
|
|
111
|
+
} finally {
|
|
112
|
+
clearInterval(timer);
|
|
113
|
+
}
|
|
114
|
+
if (result && result.success) {
|
|
115
|
+
report(
|
|
116
|
+
context,
|
|
117
|
+
`[Resume] snapshot of ${context.sessionName} committed after ${formatElapsed((Date.now() - started) / 1000)}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function detail(result, fallback) {
|
|
124
|
+
return (
|
|
125
|
+
String(result?.stderr || '').trim() ||
|
|
126
|
+
result?.error ||
|
|
127
|
+
(result?.status !== undefined && result?.status !== null
|
|
128
|
+
? `exit code ${result.status}`
|
|
129
|
+
: fallback)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Run `--snapshot-prepare` in the stopped original. A failure is reported and
|
|
135
|
+
* the snapshot continues with the files as they are.
|
|
136
|
+
* @param {string} sessionName - Original container
|
|
137
|
+
* @param {string} prepare - Shell command run with `sh -c`
|
|
138
|
+
* @param {Function} runner - Sync command runner
|
|
139
|
+
* @param {object} context - {logPath, notify}
|
|
140
|
+
* @returns {{success: boolean, message: string}}
|
|
141
|
+
*/
|
|
142
|
+
function runSnapshotPrepare(sessionName, prepare, runner, context) {
|
|
143
|
+
const docker = getDockerCommand();
|
|
144
|
+
const started = runner(docker, ['start', sessionName]);
|
|
145
|
+
if (!started.success) {
|
|
146
|
+
const message = `[Resume] snapshot prepare skipped: could not start ${sessionName}: ${detail(started, 'docker start failed')}`;
|
|
147
|
+
report(context, message);
|
|
148
|
+
return { success: false, message };
|
|
149
|
+
}
|
|
150
|
+
let result;
|
|
151
|
+
try {
|
|
152
|
+
const timeout =
|
|
153
|
+
positiveSeconds(
|
|
154
|
+
process.env.START_SNAPSHOT_PREPARE_TIMEOUT,
|
|
155
|
+
DEFAULT_PREPARE_TIMEOUT_SECONDS
|
|
156
|
+
) * 1000;
|
|
157
|
+
result = runner(docker, ['exec', sessionName, 'sh', '-c', prepare], {
|
|
158
|
+
timeout,
|
|
159
|
+
});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
result = { success: false, error: error.message };
|
|
162
|
+
} finally {
|
|
163
|
+
// Never commit a running original, and never leave it running.
|
|
164
|
+
runner(docker, ['kill', sessionName]);
|
|
165
|
+
runner(docker, ['wait', sessionName]);
|
|
166
|
+
}
|
|
167
|
+
const success = Boolean(result.success);
|
|
168
|
+
const message = success
|
|
169
|
+
? `[Resume] snapshot prepare finished in ${sessionName}`
|
|
170
|
+
: `[Resume] snapshot prepare failed in ${sessionName} (${detail(result, 'docker exec failed')}); snapshotting the files as they are`;
|
|
171
|
+
report(context, message);
|
|
172
|
+
return { success, message };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sleep(ms) {
|
|
176
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Remove the stopped original once the replacement has survived the delay.
|
|
181
|
+
* @param {object} plan - Resume plan with sessionName and newSessionName
|
|
182
|
+
* @param {number} delaySeconds - Startup window
|
|
183
|
+
* @param {Function} runner - Sync command runner
|
|
184
|
+
* @param {object} context - {logPath, notify, sleep}
|
|
185
|
+
* @returns {Promise<string>} The note describing what happened
|
|
186
|
+
*/
|
|
187
|
+
async function removeOriginalAfter(plan, delaySeconds, runner, context) {
|
|
188
|
+
report(
|
|
189
|
+
context,
|
|
190
|
+
`[Resume] Checking ${plan.newSessionName} again in ${formatElapsed(delaySeconds)} before removing ${plan.sessionName}`
|
|
191
|
+
);
|
|
192
|
+
await (context.sleep || sleep)(delaySeconds * 1000);
|
|
193
|
+
const docker = getDockerCommand();
|
|
194
|
+
const running = runner(docker, [
|
|
195
|
+
'inspect',
|
|
196
|
+
'-f',
|
|
197
|
+
'{{.State.Running}}',
|
|
198
|
+
plan.newSessionName,
|
|
199
|
+
]);
|
|
200
|
+
let note;
|
|
201
|
+
if (running.success && String(running.stdout).trim() === 'true') {
|
|
202
|
+
const removed = runner(docker, ['rm', plan.sessionName]);
|
|
203
|
+
note = removed.success
|
|
204
|
+
? `Stopped original container removed: ${plan.sessionName}`
|
|
205
|
+
: `Original container retained: ${detail(removed, 'docker rm failed')}`;
|
|
206
|
+
} else {
|
|
207
|
+
note = `Original container retained: the snapshot container is not running ${formatElapsed(delaySeconds)} after launch.`;
|
|
208
|
+
}
|
|
209
|
+
if (context.logPath) {
|
|
210
|
+
appendLogFile(context.logPath, `${note}\n`);
|
|
211
|
+
}
|
|
212
|
+
return note;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = {
|
|
216
|
+
commitWithProgress,
|
|
217
|
+
formatElapsed,
|
|
218
|
+
progressIntervalSeconds,
|
|
219
|
+
removeOriginalAfter,
|
|
220
|
+
runCommandAsync,
|
|
221
|
+
runSnapshotPrepare,
|
|
222
|
+
};
|
|
@@ -8,6 +8,30 @@ const GiB = 1024 ** 3;
|
|
|
8
8
|
const SNAPSHOT_RESERVE_BYTES = 10 * GiB;
|
|
9
9
|
const SNAPSHOT_IMAGE_LABEL = 'start-command.snapshot-image';
|
|
10
10
|
|
|
11
|
+
/** sysexits.h EX_TEMPFAIL: the resume can succeed when retried later. */
|
|
12
|
+
const EX_TEMPFAIL = 75;
|
|
13
|
+
|
|
14
|
+
function tempfail(message) {
|
|
15
|
+
const error = new Error(message);
|
|
16
|
+
error.exitCode = EX_TEMPFAIL;
|
|
17
|
+
return error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatSeconds(seconds) {
|
|
21
|
+
return `${Number(seconds.toFixed(3))}s`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function notifier(options) {
|
|
25
|
+
return options.notify || ((line) => process.stderr.write(`${line}\n`));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Take the host-wide snapshot lock. With `waitSeconds` above 0 a busy lock is
|
|
30
|
+
* waited for (issue #224); the wait is announced once with the holder's name.
|
|
31
|
+
* @param {object} [options] - {lockPath, holder, waitSeconds, notify}
|
|
32
|
+
* @returns {LockManager} The held lock
|
|
33
|
+
* @throws {Error} With `exitCode` 75 when the lock stays busy
|
|
34
|
+
*/
|
|
11
35
|
function acquireSnapshotLock(options = {}) {
|
|
12
36
|
const lockPath =
|
|
13
37
|
options.lockPath ||
|
|
@@ -17,6 +41,9 @@ function acquireSnapshotLock(options = {}) {
|
|
|
17
41
|
'start-command-docker-snapshot.lock'
|
|
18
42
|
);
|
|
19
43
|
const lock = new LockManager(lockPath);
|
|
44
|
+
if (options.holder) {
|
|
45
|
+
lock.metadata = { holder: options.holder };
|
|
46
|
+
}
|
|
20
47
|
// The ordinary store lock is held only for a brief write. A snapshot can
|
|
21
48
|
// legitimately take hours, so never reclaim this lock merely for its age.
|
|
22
49
|
lock.isLockStale = (data) => {
|
|
@@ -35,12 +62,28 @@ function acquireSnapshotLock(options = {}) {
|
|
|
35
62
|
return error.code === 'ESRCH';
|
|
36
63
|
}
|
|
37
64
|
};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
65
|
+
const waitMs = Math.max(0, (options.waitSeconds || 0) * 1000);
|
|
66
|
+
if (lock.acquire(Math.min(1000, waitMs || 1000))) {
|
|
67
|
+
return lock;
|
|
68
|
+
}
|
|
69
|
+
const holder = lock.readLockFile()?.holder;
|
|
70
|
+
const busy = holder
|
|
71
|
+
? `A Docker snapshot resume of ${holder} is in progress on this host.`
|
|
72
|
+
: 'A Docker snapshot resume is in progress on this host.';
|
|
73
|
+
if (waitMs <= 1000) {
|
|
74
|
+
throw tempfail(
|
|
75
|
+
`${busy} Retry after it finishes, or wait for it with --snapshot-wait <seconds>.`
|
|
41
76
|
);
|
|
42
77
|
}
|
|
43
|
-
|
|
78
|
+
notifier(options)(
|
|
79
|
+
`[Resume] Waiting up to ${formatSeconds(waitMs / 1000)} for the snapshot of ${holder || 'another container'} to finish before snapshotting ${options.holder || 'this container'}.`
|
|
80
|
+
);
|
|
81
|
+
if (lock.acquire(waitMs - 1000)) {
|
|
82
|
+
return lock;
|
|
83
|
+
}
|
|
84
|
+
throw tempfail(
|
|
85
|
+
`${busy} Still busy after waiting ${formatSeconds(waitMs / 1000)}; retry after it finishes.`
|
|
86
|
+
);
|
|
44
87
|
}
|
|
45
88
|
|
|
46
89
|
function containerdRoot(config = '') {
|
|
@@ -73,8 +116,10 @@ function readFreeBytes(root) {
|
|
|
73
116
|
return Number(stats.bavail * stats.bsize);
|
|
74
117
|
}
|
|
75
118
|
|
|
76
|
-
function
|
|
77
|
-
|
|
119
|
+
function measureWritableLayer(containerName, runner, docker, options) {
|
|
120
|
+
if (options.sizeHint !== null && options.sizeHint !== undefined) {
|
|
121
|
+
return { size: options.sizeHint, source: 'size hint' };
|
|
122
|
+
}
|
|
78
123
|
const sizeResult = runner(docker, [
|
|
79
124
|
'inspect',
|
|
80
125
|
'--size',
|
|
@@ -93,6 +138,10 @@ function preflightSnapshot(containerName, runner, options = {}) {
|
|
|
93
138
|
'Cannot determine the writable layer size; refusing Docker snapshot resume.'
|
|
94
139
|
);
|
|
95
140
|
}
|
|
141
|
+
return { size, source: null };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function imageStoreRoots(runner, docker, options) {
|
|
96
145
|
const infoResult = runner(docker, ['info', '--format', '{{json .}}']);
|
|
97
146
|
let info;
|
|
98
147
|
try {
|
|
@@ -113,9 +162,12 @@ function preflightSnapshot(containerName, runner, options = {}) {
|
|
|
113
162
|
) {
|
|
114
163
|
roots.push(options.containerdRoot || defaultContainerdRoot());
|
|
115
164
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
165
|
+
return [...new Set(roots)];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @returns {?object} The first root without enough space, or null */
|
|
169
|
+
function findShortRoot(roots, required, free) {
|
|
170
|
+
for (const root of roots) {
|
|
119
171
|
let available;
|
|
120
172
|
try {
|
|
121
173
|
available = free(root);
|
|
@@ -126,16 +178,75 @@ function preflightSnapshot(containerName, runner, options = {}) {
|
|
|
126
178
|
);
|
|
127
179
|
}
|
|
128
180
|
if (!Number.isFinite(available) || available < required) {
|
|
129
|
-
|
|
130
|
-
|
|
181
|
+
return { root, available };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sleepSync(ms) {
|
|
188
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Refuse a snapshot that could fill the disk. The writable layer is measured
|
|
193
|
+
* once (or taken from `sizeHint`); with `diskWaitSeconds` the free space is
|
|
194
|
+
* re-checked every `diskPollSeconds` until it suffices (issue #224).
|
|
195
|
+
* @param {object} [options] - {sizeHint, diskWaitSeconds, diskPollSeconds,
|
|
196
|
+
* reserveBytes, freeBytes, containerdRoot, notify, sleep, now}
|
|
197
|
+
* @throws {Error} With `exitCode` 75 when the disk stays short
|
|
198
|
+
*/
|
|
199
|
+
function preflightSnapshot(containerName, runner, options = {}) {
|
|
200
|
+
const docker = require('./docker-cleanup').getDockerCommand();
|
|
201
|
+
const { size, source } = measureWritableLayer(
|
|
202
|
+
containerName,
|
|
203
|
+
runner,
|
|
204
|
+
docker,
|
|
205
|
+
options
|
|
206
|
+
);
|
|
207
|
+
const roots = imageStoreRoots(runner, docker, options);
|
|
208
|
+
const required = 2 * size + (options.reserveBytes ?? SNAPSHOT_RESERVE_BYTES);
|
|
209
|
+
const free = options.freeBytes || readFreeBytes;
|
|
210
|
+
const waitMs = Math.max(0, (options.diskWaitSeconds || 0) * 1000);
|
|
211
|
+
const pollMs = Math.max(
|
|
212
|
+
1,
|
|
213
|
+
(options.diskPollSeconds ??
|
|
214
|
+
Number(process.env.START_SNAPSHOT_DISK_POLL || 10)) * 1000
|
|
215
|
+
);
|
|
216
|
+
const now = options.now || Date.now;
|
|
217
|
+
const sleep = options.sleep || sleepSync;
|
|
218
|
+
const started = now();
|
|
219
|
+
let waited = false;
|
|
220
|
+
for (;;) {
|
|
221
|
+
const short = findShortRoot(roots, required, free);
|
|
222
|
+
if (!short) {
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
const detail = `Insufficient disk at ${short.root}: need ${(required / GiB).toFixed(2)} GiB (2 × writable layer plus reserve), available ${(short.available / GiB).toFixed(2)} GiB.`;
|
|
226
|
+
const left = waitMs - (now() - started);
|
|
227
|
+
if (left <= 0) {
|
|
228
|
+
throw tempfail(
|
|
229
|
+
waited
|
|
230
|
+
? `${detail} Still short after waiting ${formatSeconds(waitMs / 1000)}; Docker snapshot resume refused.`
|
|
231
|
+
: `${detail} Docker snapshot resume refused.`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (!waited) {
|
|
235
|
+
notifier(options)(
|
|
236
|
+
`[Resume] Waiting up to ${formatSeconds(waitMs / 1000)} for disk: ${detail}`
|
|
131
237
|
);
|
|
238
|
+
waited = true;
|
|
132
239
|
}
|
|
240
|
+
sleep(Math.min(pollMs, left));
|
|
133
241
|
}
|
|
242
|
+
const measured = source
|
|
243
|
+
? `${(size / GiB).toFixed(2)} GiB (${source})`
|
|
244
|
+
: `${(size / GiB).toFixed(2)} GiB`;
|
|
134
245
|
return {
|
|
135
246
|
size,
|
|
136
247
|
required,
|
|
137
248
|
roots,
|
|
138
|
-
message: `[Resume] snapshotting ${
|
|
249
|
+
message: `[Resume] snapshotting ${measured}; disk preflight passed (${(required / GiB).toFixed(2)} GiB required).`,
|
|
139
250
|
};
|
|
140
251
|
}
|
|
141
252
|
|
|
@@ -155,6 +266,7 @@ function snapshotImageCleanupSnippet(
|
|
|
155
266
|
}
|
|
156
267
|
|
|
157
268
|
module.exports = {
|
|
269
|
+
EX_TEMPFAIL,
|
|
158
270
|
acquireSnapshotLock,
|
|
159
271
|
containerdRoot,
|
|
160
272
|
preflightSnapshot,
|
|
@@ -7,6 +7,8 @@ const TERMINAL_FIELDS = [
|
|
|
7
7
|
'observedAt',
|
|
8
8
|
'staleDetectedAt',
|
|
9
9
|
'containerStartedAt',
|
|
10
|
+
'containerState',
|
|
11
|
+
'containerRemovedObservedAt',
|
|
10
12
|
'exitReason',
|
|
11
13
|
'oomKilled',
|
|
12
14
|
'memoryExhausted',
|
|
@@ -44,6 +46,21 @@ function readAttemptActivity(record) {
|
|
|
44
46
|
return record;
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
/** Number of the record's current attempt: 1 until it is resumed. */
|
|
50
|
+
function currentAttemptNumber(record) {
|
|
51
|
+
return (
|
|
52
|
+
record?.attempt?.number ||
|
|
53
|
+
(Number(record?.options?.resumeCount) || 0) +
|
|
54
|
+
(Number(record?.options?.recoveryAttempts) || 0) +
|
|
55
|
+
1
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Number the next attempt of `record` gets (`START_ATTEMPT`, issue #230). */
|
|
60
|
+
function nextAttemptNumber(record) {
|
|
61
|
+
return currentAttemptNumber(record) + 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
47
64
|
function createAttempt(record, plan) {
|
|
48
65
|
let logOffset = null;
|
|
49
66
|
if (record.logPath) {
|
|
@@ -56,11 +73,7 @@ function createAttempt(record, plan) {
|
|
|
56
73
|
}
|
|
57
74
|
}
|
|
58
75
|
return {
|
|
59
|
-
number:
|
|
60
|
-
(record.attempt?.number ||
|
|
61
|
-
(Number(record.options?.resumeCount) || 0) +
|
|
62
|
-
(Number(record.options?.recoveryAttempts) || 0) +
|
|
63
|
-
1) + 1,
|
|
76
|
+
number: nextAttemptNumber(record),
|
|
64
77
|
startedAt: new Date().toISOString(),
|
|
65
78
|
logOffset,
|
|
66
79
|
lastOutputAt: null,
|
|
@@ -76,10 +89,7 @@ function archiveAttempt(record) {
|
|
|
76
89
|
readAttemptActivity(record);
|
|
77
90
|
const snapshot = {
|
|
78
91
|
...(record.attempt || {
|
|
79
|
-
number:
|
|
80
|
-
(Number(record.options?.resumeCount) || 0) +
|
|
81
|
-
(Number(record.options?.recoveryAttempts) || 0) +
|
|
82
|
-
1,
|
|
92
|
+
number: currentAttemptNumber(record),
|
|
83
93
|
startedAt: record.options?.resumedAt || record.startTime,
|
|
84
94
|
logOffset: null,
|
|
85
95
|
}),
|
|
@@ -174,6 +184,8 @@ module.exports = {
|
|
|
174
184
|
appendLifecycle,
|
|
175
185
|
archiveAttempt,
|
|
176
186
|
createAttempt,
|
|
187
|
+
currentAttemptNumber,
|
|
188
|
+
nextAttemptNumber,
|
|
177
189
|
patchAttempt,
|
|
178
190
|
readAttemptActivity,
|
|
179
191
|
readAttemptLogTail,
|