start-command 0.34.1 → 0.35.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 +6 -0
- package/package.json +2 -2
- package/src/lib/args-parser.js +10 -0
- package/src/lib/docker-cleanup.js +28 -5
- package/src/lib/docker-recovery-options.js +84 -0
- package/src/lib/docker-resource-limits.js +211 -0
- package/src/lib/docker-runtime-args.js +141 -0
- package/src/lib/execution-control.js +39 -0
- package/src/lib/execution-recovery.js +350 -0
- package/src/lib/execution-resume.js +68 -4
- package/src/lib/isolation.js +20 -92
- package/src/lib/usage.js +3 -0
- package/test/docker-runtime-options.js +6 -0
- package/test/regression-176.js +897 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch-time recovery for killed detached docker sessions (issue #176).
|
|
3
|
+
*
|
|
4
|
+
* `$ --isolated docker --detached --on-kill-resume 3 --recovery-command B -- A`
|
|
5
|
+
* runs `A`; when the main process is killed (exit 137 / `OOMKilled`), the
|
|
6
|
+
* detached completion watcher hands the inspected facts to this module, which
|
|
7
|
+
* restarts the *same* container so `B` continues on the same filesystem, with
|
|
8
|
+
* the same resource limits (they live in the container's HostConfig), under
|
|
9
|
+
* the same execution UUID and appending to the same log file.
|
|
10
|
+
*
|
|
11
|
+
* How `B` replaces `A` inside the same container: a container launched with a
|
|
12
|
+
* recovery command runs a tiny selector as its entrypoint command. The
|
|
13
|
+
* selector runs `A` normally, but runs `B` when the marker file exists. Before
|
|
14
|
+
* `docker start`, the marker (holding the attempt number) is copied into the
|
|
15
|
+
* stopped container with `docker cp`. Without a recovery command the original
|
|
16
|
+
* command is simply started again.
|
|
17
|
+
*
|
|
18
|
+
* Like `detached-finalize.js`, the entry point runs as a short-lived child of
|
|
19
|
+
* the watcher on a host whose CLI invocation is long gone, so it never throws.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
getDockerCommand,
|
|
28
|
+
getDockerContainerCleanupPolicy,
|
|
29
|
+
startDetachedDockerCompletionWatcher,
|
|
30
|
+
} = require('./docker-cleanup');
|
|
31
|
+
const {
|
|
32
|
+
SHELL_VARS,
|
|
33
|
+
describeExitCode,
|
|
34
|
+
formatContainerPostMortem,
|
|
35
|
+
} = require('./docker-post-mortem');
|
|
36
|
+
const {
|
|
37
|
+
buildResourceLimitsStatusLine,
|
|
38
|
+
readDockerResourceLimits,
|
|
39
|
+
} = require('./docker-resource-limits');
|
|
40
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
41
|
+
|
|
42
|
+
/** File whose presence makes the selector run the recovery command. */
|
|
43
|
+
const RECOVERY_MARKER_PATH = '/.start-command-recovery';
|
|
44
|
+
|
|
45
|
+
/** Environment variable carrying the attempt number into the recovery command. */
|
|
46
|
+
const RECOVERY_ATTEMPT_ENV = 'START_COMMAND_RECOVERY_ATTEMPT';
|
|
47
|
+
|
|
48
|
+
/** Exit code of a process killed with SIGKILL (the OOM killer's signal). */
|
|
49
|
+
const KILLED_EXIT_CODE = 137;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* POSIX selector run as the container command when a recovery command is set:
|
|
53
|
+
* sh -c SELECTOR start-command <shell> <flag> <recovery> <main argv...>
|
|
54
|
+
*/
|
|
55
|
+
const RECOVERY_SELECTOR =
|
|
56
|
+
's=$1; f=$2; r=$3; shift 3; ' +
|
|
57
|
+
`if [ -e ${RECOVERY_MARKER_PATH} ]; then ` +
|
|
58
|
+
`${RECOVERY_ATTEMPT_ENV}=$(cat ${RECOVERY_MARKER_PATH} 2>/dev/null); ` +
|
|
59
|
+
`export ${RECOVERY_ATTEMPT_ENV}; ` +
|
|
60
|
+
'if [ -n "$f" ]; then exec "$s" "$f" -c "$r"; fi; exec "$s" -c "$r"; fi; ' +
|
|
61
|
+
'exec "$@"';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Wrap the container command so a later `docker start` can switch to the
|
|
65
|
+
* recovery command.
|
|
66
|
+
* @param {string[]} mainArgs - Container command for the main run
|
|
67
|
+
* @param {object} params - {shell, shellFlag, recoveryCommand}
|
|
68
|
+
* @returns {string[]} Container command
|
|
69
|
+
*/
|
|
70
|
+
function buildRecoverySelectorArgs(mainArgs, params) {
|
|
71
|
+
return [
|
|
72
|
+
'sh',
|
|
73
|
+
'-c',
|
|
74
|
+
RECOVERY_SELECTOR,
|
|
75
|
+
'start-command',
|
|
76
|
+
params.shell || 'sh',
|
|
77
|
+
params.shellFlag || '',
|
|
78
|
+
params.recoveryCommand,
|
|
79
|
+
...mainArgs,
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {number|string|null} exitCode - `.State.ExitCode`
|
|
85
|
+
* @param {boolean|string|null} oomKilled - `.State.OOMKilled`
|
|
86
|
+
* @returns {boolean} Whether the main process was killed
|
|
87
|
+
*/
|
|
88
|
+
function isKilledExit(exitCode, oomKilled) {
|
|
89
|
+
return (
|
|
90
|
+
describeExitCode(exitCode).code === KILLED_EXIT_CODE ||
|
|
91
|
+
oomKilled === true ||
|
|
92
|
+
oomKilled === 'true'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Shell condition run by the completion watcher: true when the container was
|
|
98
|
+
* killed and the recovery entry point resumed it.
|
|
99
|
+
* @param {string} executionId - Execution UUID
|
|
100
|
+
* @returns {string} POSIX shell condition
|
|
101
|
+
*/
|
|
102
|
+
function buildRecoverySnippet(executionId) {
|
|
103
|
+
const v = SHELL_VARS;
|
|
104
|
+
return (
|
|
105
|
+
`{ [ "$${v.exit}" = ${KILLED_EXIT_CODE} ] || [ "$${v.oom}" = true ]; } && ` +
|
|
106
|
+
`${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
|
|
107
|
+
`${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
|
|
108
|
+
`"$${v.started}" "$${v.finished}" "$${v.error}" >/dev/null 2>&1`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The `[Recovery k/N]` separator written into the session log.
|
|
114
|
+
* @param {object} params - {attempt, maxAttempts, exitCode, oomKilled, containerName, command}
|
|
115
|
+
* @returns {string} Separator line, newline terminated
|
|
116
|
+
*/
|
|
117
|
+
function formatRecoverySeparator(params) {
|
|
118
|
+
const described = describeExitCode(params.exitCode);
|
|
119
|
+
const signal = described.signal ? `, ${described.signal}` : '';
|
|
120
|
+
const what = params.command
|
|
121
|
+
? `running recovery command: ${params.command}`
|
|
122
|
+
: 'running the original command again';
|
|
123
|
+
return (
|
|
124
|
+
`\n[Recovery ${params.attempt}/${params.maxAttempts}] ` +
|
|
125
|
+
`Main process was killed (exit ${described.code}${signal}, ` +
|
|
126
|
+
`oomKilled=${params.oomKilled === true || params.oomKilled === 'true'}); ` +
|
|
127
|
+
`resuming container ${params.containerName}, ${what}\n`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function appendToLog(logPath, text) {
|
|
132
|
+
if (!logPath) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
fs.appendFileSync(logPath, text);
|
|
137
|
+
} catch {
|
|
138
|
+
// The log is best-effort here: recovery itself must still proceed.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function writeRecoveryMarker(containerName, attempt, runner) {
|
|
143
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-recovery-'));
|
|
144
|
+
const file = path.join(dir, 'marker');
|
|
145
|
+
try {
|
|
146
|
+
fs.writeFileSync(file, `${attempt}\n`, { mode: 0o644 });
|
|
147
|
+
fs.chmodSync(file, 0o644);
|
|
148
|
+
return runner(getDockerCommand(), [
|
|
149
|
+
'cp',
|
|
150
|
+
file,
|
|
151
|
+
`${containerName}:${RECOVERY_MARKER_PATH}`,
|
|
152
|
+
]);
|
|
153
|
+
} finally {
|
|
154
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function failureDetail(result) {
|
|
159
|
+
return (
|
|
160
|
+
(result.stderr || '').trim() || result.error || `exit code ${result.status}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resume a killed execution in its own container.
|
|
166
|
+
* @param {object} params - {store, executionId, exitCode, oomKilled, startedAt,
|
|
167
|
+
* finishedAt, containerError, runner, startWatcher, now}
|
|
168
|
+
* @returns {{recovered: boolean, reason: string, attempt?: number}} Outcome
|
|
169
|
+
*/
|
|
170
|
+
function recoverKilledExecution(params = {}) {
|
|
171
|
+
const { store, executionId } = params;
|
|
172
|
+
if (!store || !executionId) {
|
|
173
|
+
return { recovered: false, reason: 'missing-arguments' };
|
|
174
|
+
}
|
|
175
|
+
const record = store.get(executionId);
|
|
176
|
+
if (!record) {
|
|
177
|
+
return { recovered: false, reason: 'record-not-found' };
|
|
178
|
+
}
|
|
179
|
+
const opts = record.options || {};
|
|
180
|
+
const maxAttempts = Number(opts.onKillResume) || 0;
|
|
181
|
+
const containerName = opts.sessionName;
|
|
182
|
+
if (opts.isolated !== 'docker' || !containerName || maxAttempts < 1) {
|
|
183
|
+
return { recovered: false, reason: 'not-configured' };
|
|
184
|
+
}
|
|
185
|
+
if (!isKilledExit(params.exitCode, params.oomKilled)) {
|
|
186
|
+
return { recovered: false, reason: 'not-killed' };
|
|
187
|
+
}
|
|
188
|
+
const log = (text) => appendToLog(record.logPath, text);
|
|
189
|
+
if (opts.stopRequestedAt) {
|
|
190
|
+
log('\n[Recovery] Not resuming: the session was stopped on request.\n');
|
|
191
|
+
return { recovered: false, reason: 'stop-requested' };
|
|
192
|
+
}
|
|
193
|
+
const used = Number(opts.recoveryAttempts) || 0;
|
|
194
|
+
if (used >= maxAttempts) {
|
|
195
|
+
log(
|
|
196
|
+
`\n[Recovery] Not resuming: all ${maxAttempts} recovery attempt(s) used.\n`
|
|
197
|
+
);
|
|
198
|
+
return { recovered: false, reason: 'attempts-exhausted' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const runner = params.runner || require('./execution-control').runCommand;
|
|
202
|
+
const now = params.now || (() => new Date());
|
|
203
|
+
const attempt = used + 1;
|
|
204
|
+
const giveUp = (reason) => {
|
|
205
|
+
log(`[Recovery ${attempt}/${maxAttempts}] Failed: ${reason}\n`);
|
|
206
|
+
return { recovered: false, reason: 'resume-failed', attempt };
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
log(
|
|
210
|
+
`\n${formatContainerPostMortem({
|
|
211
|
+
containerName,
|
|
212
|
+
exitCode: params.exitCode,
|
|
213
|
+
oomKilled: params.oomKilled,
|
|
214
|
+
startedAt: params.startedAt,
|
|
215
|
+
finishedAt: params.finishedAt,
|
|
216
|
+
error: params.containerError,
|
|
217
|
+
})}`
|
|
218
|
+
);
|
|
219
|
+
log(
|
|
220
|
+
formatRecoverySeparator({
|
|
221
|
+
attempt,
|
|
222
|
+
maxAttempts,
|
|
223
|
+
exitCode: params.exitCode,
|
|
224
|
+
oomKilled: params.oomKilled,
|
|
225
|
+
containerName,
|
|
226
|
+
command: opts.recoveryCommand || null,
|
|
227
|
+
})
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Docker keeps `docker update` limits in the HostConfig across restarts;
|
|
231
|
+
// they are read back so the log and `--status` show what the resumed run
|
|
232
|
+
// is held to, and so a later relaunch can re-apply them.
|
|
233
|
+
const resourceLimits = readDockerResourceLimits(containerName, runner);
|
|
234
|
+
const limitsLine = buildResourceLimitsStatusLine(resourceLimits);
|
|
235
|
+
if (limitsLine) {
|
|
236
|
+
log(`${limitsLine}\n`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (opts.recoveryCommand) {
|
|
240
|
+
let copied;
|
|
241
|
+
try {
|
|
242
|
+
copied = writeRecoveryMarker(containerName, attempt, runner);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
copied = { success: false, error: err.message };
|
|
245
|
+
}
|
|
246
|
+
if (!copied.success) {
|
|
247
|
+
return giveUp(`could not mark the container: ${failureDetail(copied)}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const since = now().toISOString();
|
|
252
|
+
const started = runner(getDockerCommand(), ['start', containerName]);
|
|
253
|
+
if (!started.success) {
|
|
254
|
+
return giveUp(`docker start failed: ${failureDetail(started)}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const described = describeExitCode(params.exitCode);
|
|
258
|
+
record.options = {
|
|
259
|
+
...opts,
|
|
260
|
+
recoveryAttempts: attempt,
|
|
261
|
+
recoveryHistory: [
|
|
262
|
+
...(Array.isArray(opts.recoveryHistory) ? opts.recoveryHistory : []),
|
|
263
|
+
`${attempt}: exit ${described.code}, oomKilled=${params.oomKilled === true || params.oomKilled === 'true'}, resumed at ${since}`,
|
|
264
|
+
],
|
|
265
|
+
lastRecoveryAt: since,
|
|
266
|
+
...(resourceLimits && resourceLimits.length > 0 ? { resourceLimits } : {}),
|
|
267
|
+
};
|
|
268
|
+
record.status = 'executing';
|
|
269
|
+
record.exitCode = null;
|
|
270
|
+
record.endTime = null;
|
|
271
|
+
record.exitReason = undefined;
|
|
272
|
+
record.oomKilled = undefined;
|
|
273
|
+
try {
|
|
274
|
+
store.save(record);
|
|
275
|
+
} catch {
|
|
276
|
+
// The container is already running again; the new watcher still follows
|
|
277
|
+
// it and finalizes the record when it ends.
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const startWatcher =
|
|
281
|
+
params.startWatcher || startDetachedDockerCompletionWatcher;
|
|
282
|
+
startWatcher(
|
|
283
|
+
containerName,
|
|
284
|
+
getDockerContainerCleanupPolicy(opts),
|
|
285
|
+
record.logPath || null,
|
|
286
|
+
record.uuid,
|
|
287
|
+
{ since, recoverOnKill: true }
|
|
288
|
+
);
|
|
289
|
+
return { recovered: true, reason: 'resumed', attempt };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Entry point used by the detached watcher:
|
|
294
|
+
* <runtime> execution-recovery.js <uuid> <exit> <oom> <started> <finished> <error>
|
|
295
|
+
* Exits 0 only when the container was resumed; any other outcome lets the
|
|
296
|
+
* watcher continue with its normal cleanup, footer and finalization.
|
|
297
|
+
* @param {string[]} argv - Positional arguments
|
|
298
|
+
* @returns {number} Process exit code
|
|
299
|
+
*/
|
|
300
|
+
function main(argv) {
|
|
301
|
+
const [executionId, exitCode, oomKilled, startedAt, finishedAt, error] = argv;
|
|
302
|
+
if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
|
|
303
|
+
return 1;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const { ExecutionStore } = require('./execution-store');
|
|
307
|
+
const store = new ExecutionStore(
|
|
308
|
+
process.env.START_APP_FOLDER
|
|
309
|
+
? { appFolder: path.resolve(process.env.START_APP_FOLDER) }
|
|
310
|
+
: {}
|
|
311
|
+
);
|
|
312
|
+
const outcome = recoverKilledExecution({
|
|
313
|
+
store,
|
|
314
|
+
executionId,
|
|
315
|
+
exitCode,
|
|
316
|
+
oomKilled,
|
|
317
|
+
startedAt,
|
|
318
|
+
finishedAt,
|
|
319
|
+
containerError: error,
|
|
320
|
+
});
|
|
321
|
+
return outcome.recovered ? 0 : 1;
|
|
322
|
+
} catch (err) {
|
|
323
|
+
// The watcher discards this output; START_DEBUG=1 shows it when the
|
|
324
|
+
// entry point is run by hand.
|
|
325
|
+
if (process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true') {
|
|
326
|
+
console.error(`[DEBUG] execution-recovery failed: ${err.stack || err}`);
|
|
327
|
+
}
|
|
328
|
+
return 1;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
KILLED_EXIT_CODE,
|
|
334
|
+
RECOVERY_ATTEMPT_ENV,
|
|
335
|
+
RECOVERY_MARKER_PATH,
|
|
336
|
+
RECOVERY_SELECTOR,
|
|
337
|
+
buildRecoverySelectorArgs,
|
|
338
|
+
buildRecoverySnippet,
|
|
339
|
+
formatRecoverySeparator,
|
|
340
|
+
isKilledExit,
|
|
341
|
+
main,
|
|
342
|
+
recoverKilledExecution,
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// After `module.exports`: run as the watcher's entry point, recovery starts the
|
|
346
|
+
// next watcher through `docker-cleanup.js`, which requires this module back
|
|
347
|
+
// and needs its exports complete.
|
|
348
|
+
if (require.main === module) {
|
|
349
|
+
process.exitCode = main(process.argv.slice(2));
|
|
350
|
+
}
|
|
@@ -25,6 +25,12 @@ const {
|
|
|
25
25
|
startDetachedDockerCompletionWatcher,
|
|
26
26
|
} = require('./docker-cleanup');
|
|
27
27
|
const { escapeForLinksNotation } = require('./output-blocks');
|
|
28
|
+
const {
|
|
29
|
+
buildResourceLimitsStatusLine,
|
|
30
|
+
normalizeResourceLimits,
|
|
31
|
+
readDockerResourceLimits,
|
|
32
|
+
} = require('./docker-resource-limits');
|
|
33
|
+
const { appendLogFile } = require('./isolation-log-utils');
|
|
28
34
|
const { runCommand } = require('./execution-control');
|
|
29
35
|
const { SessionState, probeSession } = require('./session-probe');
|
|
30
36
|
const { getDockerNetworks } = require('./docker-network-lifecycle');
|
|
@@ -95,6 +101,8 @@ function buildLaunchOptions(record) {
|
|
|
95
101
|
mounts: opts.mounts || [],
|
|
96
102
|
networks: opts.networks || [],
|
|
97
103
|
networkAliases: opts.networkAliases || [],
|
|
104
|
+
// Limits captured from the container on an earlier resume (issue #176).
|
|
105
|
+
resourceLimits: normalizeResourceLimits(opts.resourceLimits),
|
|
98
106
|
// Append to the same log so one logical session keeps one gap-free record.
|
|
99
107
|
logPath: record.logPath || null,
|
|
100
108
|
};
|
|
@@ -152,9 +160,16 @@ function buildSnapshotStartSteps({
|
|
|
152
160
|
* @param {object} record - Execution record
|
|
153
161
|
* @param {?string} newCommand - Replacement command (from `-- <command>`)
|
|
154
162
|
* @param {object} probe - Result of `probeSession`
|
|
163
|
+
* @param {?string[]} [liveResourceLimits] - Limits read from the stopped
|
|
164
|
+
* container with `docker inspect`; falls back to the stored ones when null
|
|
155
165
|
* @returns {object} Resume plan, or {error}
|
|
156
166
|
*/
|
|
157
|
-
function buildResumePlan(
|
|
167
|
+
function buildResumePlan(
|
|
168
|
+
record,
|
|
169
|
+
newCommand,
|
|
170
|
+
probe = {},
|
|
171
|
+
liveResourceLimits = null
|
|
172
|
+
) {
|
|
158
173
|
const opts = (record && record.options) || {};
|
|
159
174
|
const backend = opts.isolated;
|
|
160
175
|
const sessionName = opts.sessionName;
|
|
@@ -208,6 +223,11 @@ function buildResumePlan(record, newCommand, probe = {}) {
|
|
|
208
223
|
// Lazily required: isolation.js pulls in this module's siblings, so a
|
|
209
224
|
// top-level require would create a cycle.
|
|
210
225
|
const { buildDockerRuntimeArgs } = require('./isolation');
|
|
226
|
+
// `docker commit` keeps the filesystem but not the HostConfig, so the
|
|
227
|
+
// limits must be passed to the new container explicitly (issue #176).
|
|
228
|
+
const resourceLimits = normalizeResourceLimits(
|
|
229
|
+
liveResourceLimits || opts.resourceLimits
|
|
230
|
+
);
|
|
211
231
|
const snapshotImage = buildSnapshotImageName(sessionName, attempt);
|
|
212
232
|
const newSessionName = buildResumedSessionName(sessionName, attempt);
|
|
213
233
|
return {
|
|
@@ -218,6 +238,7 @@ function buildResumePlan(record, newCommand, probe = {}) {
|
|
|
218
238
|
snapshotImage,
|
|
219
239
|
command,
|
|
220
240
|
attempt,
|
|
241
|
+
resourceLimits,
|
|
221
242
|
steps: [
|
|
222
243
|
{
|
|
223
244
|
command: getDockerCommand(),
|
|
@@ -229,7 +250,7 @@ function buildResumePlan(record, newCommand, probe = {}) {
|
|
|
229
250
|
'--name',
|
|
230
251
|
newSessionName,
|
|
231
252
|
...(opts.user ? ['--user', opts.user] : []),
|
|
232
|
-
...buildDockerRuntimeArgs(opts),
|
|
253
|
+
...buildDockerRuntimeArgs({ ...opts, resourceLimits }),
|
|
233
254
|
snapshotImage,
|
|
234
255
|
'sh',
|
|
235
256
|
'-c',
|
|
@@ -280,6 +301,11 @@ function formatResumeResultAsLinksNotation(result) {
|
|
|
280
301
|
` snapshotImage ${escapeForLinksNotation(result.snapshotImage)}`
|
|
281
302
|
);
|
|
282
303
|
}
|
|
304
|
+
if (result.resourceLimits && result.resourceLimits.length > 0) {
|
|
305
|
+
lines.push(
|
|
306
|
+
` resourceLimits ${escapeForLinksNotation(result.resourceLimits.join(' '))}`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
283
309
|
lines.push(` command ${escapeForLinksNotation(result.command)}`);
|
|
284
310
|
lines.push(` message ${escapeForLinksNotation(result.message)}`);
|
|
285
311
|
return lines.join('\n');
|
|
@@ -296,6 +322,9 @@ function formatResumeResult(result, outputFormat) {
|
|
|
296
322
|
`Backend: ${result.backend}`,
|
|
297
323
|
`Session Name: ${result.sessionName}`,
|
|
298
324
|
`Command: ${result.command}`,
|
|
325
|
+
...(result.resourceLimits && result.resourceLimits.length > 0
|
|
326
|
+
? [`Resource Limits: ${result.resourceLimits.join(' ')}`]
|
|
327
|
+
: []),
|
|
299
328
|
result.message,
|
|
300
329
|
].join('\n');
|
|
301
330
|
}
|
|
@@ -314,6 +343,11 @@ function applyResumeToRecord(record, plan, containerId) {
|
|
|
314
343
|
const options = { ...(record.options || {}) };
|
|
315
344
|
options.resumeCount = plan.attempt;
|
|
316
345
|
options.resumedAt = new Date().toISOString();
|
|
346
|
+
// A resume is a new deliberate start: launch-time recovery applies again.
|
|
347
|
+
delete options.stopRequestedAt;
|
|
348
|
+
if (plan.resourceLimits && plan.resourceLimits.length > 0) {
|
|
349
|
+
options.resourceLimits = plan.resourceLimits;
|
|
350
|
+
}
|
|
317
351
|
|
|
318
352
|
if (plan.newSessionName) {
|
|
319
353
|
options.sessionNameHistory = [
|
|
@@ -365,7 +399,25 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
365
399
|
|
|
366
400
|
const runner = deps.runner || runCommand;
|
|
367
401
|
const probeFn = deps.probe || ((r) => probeSession(r, runner));
|
|
368
|
-
const
|
|
402
|
+
const probe = probeFn(record);
|
|
403
|
+
const opts = record.options || {};
|
|
404
|
+
// Only a snapshot resume creates a new container, so only it needs the old
|
|
405
|
+
// container's limits (issue #176).
|
|
406
|
+
const liveResourceLimits =
|
|
407
|
+
deps.command &&
|
|
408
|
+
opts.isolated === 'docker' &&
|
|
409
|
+
opts.sessionName &&
|
|
410
|
+
probe &&
|
|
411
|
+
!probe.alive &&
|
|
412
|
+
probe.state === SessionState.STOPPED
|
|
413
|
+
? readDockerResourceLimits(opts.sessionName, runner)
|
|
414
|
+
: null;
|
|
415
|
+
const plan = buildResumePlan(
|
|
416
|
+
record,
|
|
417
|
+
deps.command || null,
|
|
418
|
+
probe,
|
|
419
|
+
liveResourceLimits
|
|
420
|
+
);
|
|
369
421
|
if (plan.error) {
|
|
370
422
|
return { success: false, error: plan.error };
|
|
371
423
|
}
|
|
@@ -404,6 +456,11 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
404
456
|
containerId = (result.stdout || '').trim() || containerId;
|
|
405
457
|
}
|
|
406
458
|
|
|
459
|
+
const limitsLine = buildResourceLimitsStatusLine(plan.resourceLimits);
|
|
460
|
+
if (limitsLine && record.logPath) {
|
|
461
|
+
appendLogFile(record.logPath, `${limitsLine}\n`);
|
|
462
|
+
}
|
|
463
|
+
|
|
407
464
|
// The completion watcher died with the previous run (or with the
|
|
408
465
|
// supervisor), so a new one must follow the resumed container.
|
|
409
466
|
const startWatcher =
|
|
@@ -412,7 +469,13 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
412
469
|
activeSessionName(plan),
|
|
413
470
|
getDockerContainerCleanupPolicy(record.options || {}),
|
|
414
471
|
record.logPath || null,
|
|
415
|
-
record.uuid || null
|
|
472
|
+
record.uuid || null,
|
|
473
|
+
// `docker start` re-runs the launch-time selector, so the session keeps
|
|
474
|
+
// its kill recovery (issue #176); a snapshot container has no selector.
|
|
475
|
+
{
|
|
476
|
+
recoverOnKill:
|
|
477
|
+
plan.mode === ResumeMode.DOCKER_START && Boolean(opts.onKillResume),
|
|
478
|
+
}
|
|
416
479
|
);
|
|
417
480
|
}
|
|
418
481
|
|
|
@@ -431,6 +494,7 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
431
494
|
sessionName: updated.options.sessionName,
|
|
432
495
|
previousSessionName,
|
|
433
496
|
snapshotImage: plan.snapshotImage || null,
|
|
497
|
+
resourceLimits: plan.resourceLimits || null,
|
|
434
498
|
command: plan.command,
|
|
435
499
|
message: plan.message,
|
|
436
500
|
},
|
package/src/lib/isolation.js
CHANGED
|
@@ -505,96 +505,12 @@ const {
|
|
|
505
505
|
canRunLinuxDockerImages,
|
|
506
506
|
} = require('./docker-utils');
|
|
507
507
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
* @returns {string[]} Docker CLI arguments
|
|
515
|
-
*/
|
|
516
|
-
function buildDockerRuntimeArgs(options = {}) {
|
|
517
|
-
const args = [];
|
|
518
|
-
if (options.privileged) {
|
|
519
|
-
args.push('--privileged');
|
|
520
|
-
}
|
|
521
|
-
for (const envVar of options.env || []) {
|
|
522
|
-
args.push('-e', envVar);
|
|
523
|
-
}
|
|
524
|
-
for (const volume of options.volumes || []) {
|
|
525
|
-
args.push('-v', volume);
|
|
526
|
-
}
|
|
527
|
-
for (const mount of options.mounts || []) {
|
|
528
|
-
args.push('--mount', mount);
|
|
529
|
-
}
|
|
530
|
-
const [firstNetwork] = getDockerNetworks(options);
|
|
531
|
-
if (firstNetwork) {
|
|
532
|
-
args.push('--network', firstNetwork);
|
|
533
|
-
}
|
|
534
|
-
for (const alias of options.networkAliases || []) {
|
|
535
|
-
args.push('--network-alias', alias);
|
|
536
|
-
}
|
|
537
|
-
return args;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Build the human-readable `[Isolation]` status lines for docker runtime
|
|
542
|
-
* options (volumes, mounts, env, privileged). Empty collections and a falsy
|
|
543
|
-
* privileged flag contribute no lines.
|
|
544
|
-
* @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases)
|
|
545
|
-
* @returns {string[]} Status lines for the start block / log header
|
|
546
|
-
*/
|
|
547
|
-
function buildDockerRuntimeStatusLines(options = {}) {
|
|
548
|
-
const lines = [];
|
|
549
|
-
if (options.volumes && options.volumes.length > 0) {
|
|
550
|
-
lines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`);
|
|
551
|
-
}
|
|
552
|
-
if (options.mounts && options.mounts.length > 0) {
|
|
553
|
-
lines.push(`[Isolation] Mounts: ${options.mounts.join(', ')}`);
|
|
554
|
-
}
|
|
555
|
-
if (options.env && options.env.length > 0) {
|
|
556
|
-
lines.push(`[Isolation] Env: ${options.env.join(', ')}`);
|
|
557
|
-
}
|
|
558
|
-
if (options.privileged) {
|
|
559
|
-
lines.push(`[Isolation] Privileged: true`);
|
|
560
|
-
}
|
|
561
|
-
const networks = getDockerNetworks(options);
|
|
562
|
-
if (networks.length > 0) {
|
|
563
|
-
lines.push(`[Isolation] Network: ${networks[0]}`);
|
|
564
|
-
}
|
|
565
|
-
if (networks.length > 1) {
|
|
566
|
-
lines.push(`[Isolation] Networks: ${networks.join(', ')}`);
|
|
567
|
-
}
|
|
568
|
-
if (options.networkAliases && options.networkAliases.length > 0) {
|
|
569
|
-
lines.push(
|
|
570
|
-
`[Isolation] Network aliases: ${options.networkAliases.join(', ')}`
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
return lines;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Build the execution-record metadata for docker runtime options, normalizing
|
|
578
|
-
* empty collections and a falsy privileged flag to `null`.
|
|
579
|
-
* @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases)
|
|
580
|
-
* @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean, network: ?string, networks: ?string[], networkAliases: ?string[]}}
|
|
581
|
-
*/
|
|
582
|
-
function buildDockerRuntimeMetadata(options = {}) {
|
|
583
|
-
const networks = getDockerNetworks(options);
|
|
584
|
-
return {
|
|
585
|
-
volumes:
|
|
586
|
-
options.volumes && options.volumes.length > 0 ? options.volumes : null,
|
|
587
|
-
mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
|
|
588
|
-
env: options.env && options.env.length > 0 ? options.env : null,
|
|
589
|
-
privileged: options.privileged || null,
|
|
590
|
-
network: networks[0] || null,
|
|
591
|
-
networks: networks.length > 0 ? networks : null,
|
|
592
|
-
networkAliases:
|
|
593
|
-
options.networkAliases && options.networkAliases.length > 0
|
|
594
|
-
? options.networkAliases
|
|
595
|
-
: null,
|
|
596
|
-
};
|
|
597
|
-
}
|
|
508
|
+
const {
|
|
509
|
+
buildDockerRuntimeArgs,
|
|
510
|
+
buildDockerRuntimeStatusLines,
|
|
511
|
+
buildDockerRuntimeMetadata,
|
|
512
|
+
} = require('./docker-runtime-args');
|
|
513
|
+
const { buildRecoverySelectorArgs } = require('./execution-recovery');
|
|
598
514
|
|
|
599
515
|
/**
|
|
600
516
|
* Run command in Docker container
|
|
@@ -679,11 +595,22 @@ function runInDocker(command, options = {}) {
|
|
|
679
595
|
const shellArgs = shellInteractiveFlag
|
|
680
596
|
? [shellToUse, shellInteractiveFlag]
|
|
681
597
|
: [shellToUse];
|
|
682
|
-
const
|
|
598
|
+
const mainCmdArgs = isBareShell
|
|
683
599
|
? toShellWords(command)
|
|
684
600
|
: isShellInvocationWithArgs(command)
|
|
685
601
|
? buildShellWithArgsCmdArgs(effectiveCommand)
|
|
686
602
|
: [...shellArgs, '-c', effectiveCommand];
|
|
603
|
+
// A recovery command is selected by a marker that `docker cp` drops
|
|
604
|
+
// into the container before it is started again (issue #176).
|
|
605
|
+
const cmdArgs = options.recoveryCommand
|
|
606
|
+
? buildRecoverySelectorArgs(mainCmdArgs, {
|
|
607
|
+
shell: shellToUse,
|
|
608
|
+
shellFlag: shellInteractiveFlag,
|
|
609
|
+
recoveryCommand: options.keepAlive
|
|
610
|
+
? `${options.recoveryCommand}; exec ${shellToUse}`
|
|
611
|
+
: options.recoveryCommand,
|
|
612
|
+
})
|
|
613
|
+
: mainCmdArgs;
|
|
687
614
|
dockerArgs.push(options.image, ...cmdArgs);
|
|
688
615
|
|
|
689
616
|
if (DEBUG) {
|
|
@@ -736,7 +663,8 @@ function runInDocker(command, options = {}) {
|
|
|
736
663
|
containerName,
|
|
737
664
|
cleanupPolicy,
|
|
738
665
|
options.logPath,
|
|
739
|
-
options.executionId || null
|
|
666
|
+
options.executionId || null,
|
|
667
|
+
{ recoverOnKill: Boolean(options.onKillResume) }
|
|
740
668
|
);
|
|
741
669
|
|
|
742
670
|
let message = `Command started in detached docker container: ${containerName}`;
|
package/src/lib/usage.js
CHANGED
|
@@ -16,6 +16,8 @@ Options:
|
|
|
16
16
|
--privileged Run docker container in privileged mode (docker only)
|
|
17
17
|
--network <name> Connect to a named network (repeatable, docker only)
|
|
18
18
|
--network-alias <alias> Add alias to the first network (repeatable, docker only)
|
|
19
|
+
--on-kill-resume <N> Resume a killed (exit 137 / OOM) detached docker session up to N times
|
|
20
|
+
--recovery-command <cmd> Command to run in the same container on such a resume
|
|
19
21
|
--endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
|
|
20
22
|
--isolated-user, -u [name] Create isolated user with same permissions
|
|
21
23
|
--keep-user Keep isolated user after command completes
|
|
@@ -66,6 +68,7 @@ Examples:
|
|
|
66
68
|
$ --resume my-docker-session # Restart the stored command in the same container
|
|
67
69
|
$ --resume my-docker-session -- bash # Run a different command in the same container
|
|
68
70
|
$ --resume-all # Re-attach every still-running execution
|
|
71
|
+
$ -i docker -d --on-kill-resume 3 --recovery-command 'solve --resume' -- solve
|
|
69
72
|
$ --use-command-stream echo "Hello" # Use command-stream library`);
|
|
70
73
|
console.log('');
|
|
71
74
|
console.log('Piping with $:');
|
|
@@ -304,6 +304,9 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
304
304
|
network: null,
|
|
305
305
|
networks: null,
|
|
306
306
|
networkAliases: null,
|
|
307
|
+
resourceLimits: null,
|
|
308
|
+
onKillResume: null,
|
|
309
|
+
recoveryCommand: null,
|
|
307
310
|
});
|
|
308
311
|
});
|
|
309
312
|
|
|
@@ -326,6 +329,9 @@ describe('buildDockerRuntimeMetadata', () => {
|
|
|
326
329
|
network: 'hive-formal-ai',
|
|
327
330
|
networks: ['hive-formal-ai', 'public-egress'],
|
|
328
331
|
networkAliases: ['formal-ai', 'checker'],
|
|
332
|
+
resourceLimits: null,
|
|
333
|
+
onKillResume: null,
|
|
334
|
+
recoveryCommand: null,
|
|
329
335
|
}
|
|
330
336
|
);
|
|
331
337
|
});
|