start-command 0.34.0 → 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 +12 -0
- package/package.json +2 -2
- package/src/bin/cli.js +2 -1
- package/src/lib/args-parser.js +10 -0
- package/src/lib/command-stream.js +4 -2
- package/src/lib/detached-finalize.js +31 -7
- package/src/lib/docker-cleanup.js +55 -11
- package/src/lib/docker-post-mortem.js +93 -0
- 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/exit-reason.js +24 -0
- package/src/lib/isolation-log-utils.js +9 -5
- package/src/lib/isolation.js +20 -92
- package/src/lib/spawn-helpers.js +4 -2
- package/src/lib/usage.js +3 -0
- package/test/docker-runtime-options.js +6 -0
- package/test/regression-170.js +4 -1
- package/test/regression-174.js +397 -0
- package/test/regression-176.js +897 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.35.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9679323: Keep Docker resource limits across `--resume <id> -- <command>`, and add `--on-kill-resume <N>` / `--recovery-command <cmd>`. `docker commit` does not capture HostConfig, so the snapshot resume now reads the stopped container's limits with `docker inspect` (including ones applied later with `docker update`) and re-applies the non-default ones (`--memory`, `--memory-swap`, `--memory-reservation`, `--cpus`/`--cpu-quota`/`--cpu-period`, `--cpu-shares`, `--cpuset-cpus`, `--cpuset-mems`, `--pids-limit`, `--shm-size`, `--storage-opt`, `--ulimit`) to the `-resume-N` container, prints them as `[Isolation] Resource limits: ...` and stores them as `resourceLimits`. With `--on-kill-resume <N>` a detached Docker execution whose main process is killed (exit 137 or `OOMKilled`) is resumed in the same container up to N times, running `--recovery-command` (or the original command) with `START_COMMAND_RECOVERY_ATTEMPT` set. The UUID and log file are kept, each attempt is separated by a `[Recovery k/N]` line, and `--status` shows `recoveryAttempts` and `recoveryHistory`. `--stop` cancels further recovery.
|
|
8
|
+
|
|
9
|
+
## 0.34.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 2dd9a41: Never finalize a still-running detached Docker container as a success. `docker logs -f` also returns when its own write to the log fails (ENOSPC) or when dockerd restarts, so the completion watcher now asks `docker inspect -f '{{.State.Running}}'` and keeps waiting with `docker wait` until the container really exits. If the watcher still sees a running container, it notes this in the log and leaves the container and the execution record alone: no `docker rm -f`, no `Exit Code: 0` footer, and no `executed` status. A zero `FinishedAt` (`0001-01-01T00:00:00Z`) combined with an exit code of 0 is recorded as `-1` with `exitReason: watcher-lost-container`, not as a success. A child process that is killed by a signal now reports `128+n` (for example `137` for SIGKILL) instead of `0` in `runWithNodeSpawn`, `runAsIsolatedUser` and the command-stream wrapper.
|
|
14
|
+
|
|
3
15
|
## 0.34.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "start-command",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
|
|
5
5
|
"main": "src/bin/cli.js",
|
|
6
6
|
"exports": {
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"lino-objects-codec": "^0.4.0"
|
|
73
73
|
},
|
|
74
74
|
"overrides": {
|
|
75
|
-
"brace-expansion": "^5.0.
|
|
75
|
+
"brace-expansion": "^5.0.12",
|
|
76
76
|
"flatted": "^3.4.2",
|
|
77
77
|
"js-yaml": "^4.3.2"
|
|
78
78
|
}
|
package/src/bin/cli.js
CHANGED
|
@@ -38,6 +38,7 @@ const { dispatchQueryCommand, hasQueryMode } = require('../lib/query-commands');
|
|
|
38
38
|
const { printVersion } = require('../lib/version');
|
|
39
39
|
const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
|
|
40
40
|
const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
|
|
41
|
+
const { resolveChildExitCode } = require('../lib/exit-reason');
|
|
41
42
|
const { printUsage } = require('../lib/usage');
|
|
42
43
|
const { buildDisplayCommand, getCommandName } = require('../lib/shell-utils');
|
|
43
44
|
|
|
@@ -820,7 +821,7 @@ async function runDirectWithCommandStream(
|
|
|
820
821
|
// Use raw() to pass the command without auto-escaping
|
|
821
822
|
// This is important for complex commands with pipes, redirects, etc.
|
|
822
823
|
const result = await $cmd`${raw(cmd)}`;
|
|
823
|
-
exitCode = result.code
|
|
824
|
+
exitCode = resolveChildExitCode(result.code);
|
|
824
825
|
|
|
825
826
|
// Update PID if available from result
|
|
826
827
|
if (executionRecord && result.pid) {
|
package/src/lib/args-parser.js
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
|
|
44
44
|
const { getDefaultDockerImage } = require('./docker-utils');
|
|
45
45
|
const dockerNetworkOptions = require('./docker-network-options');
|
|
46
|
+
const dockerRecoveryOptions = require('./docker-recovery-options');
|
|
46
47
|
const { parseSequence, isSequence } = require('./sequence-parser');
|
|
47
48
|
const { buildCommandString } = require('./shell-utils');
|
|
48
49
|
const {
|
|
@@ -194,6 +195,8 @@ function parseArgs(args) {
|
|
|
194
195
|
network: null, // First Docker network name (compatibility accessor)
|
|
195
196
|
networks: [], // Ordered Docker network names
|
|
196
197
|
networkAliases: [], // Docker network-scoped aliases
|
|
198
|
+
onKillResume: null, // Resume up to N times when the docker main process is killed (issue #176)
|
|
199
|
+
recoveryCommand: null, // Command run in the same container on such a resume (issue #176)
|
|
197
200
|
endpoint: null, // SSH endpoint (current level, e.g., user@host)
|
|
198
201
|
endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
|
|
199
202
|
user: false, // Create isolated user
|
|
@@ -410,6 +413,11 @@ function parseOption(args, index, options) {
|
|
|
410
413
|
return networkOption;
|
|
411
414
|
}
|
|
412
415
|
|
|
416
|
+
const recoveryOption = dockerRecoveryOptions.parse(args, index, options);
|
|
417
|
+
if (recoveryOption) {
|
|
418
|
+
return recoveryOption;
|
|
419
|
+
}
|
|
420
|
+
|
|
413
421
|
// --endpoint (for ssh) - supports sequence for stacked isolation
|
|
414
422
|
if (arg === '--endpoint') {
|
|
415
423
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
@@ -746,6 +754,8 @@ function validateOptions(options) {
|
|
|
746
754
|
validateDockerRuntimeOptionsRequireDocker(options);
|
|
747
755
|
}
|
|
748
756
|
|
|
757
|
+
dockerRecoveryOptions.validate(options);
|
|
758
|
+
|
|
749
759
|
// Session name is only valid with isolation
|
|
750
760
|
if (options.session && !options.isolated) {
|
|
751
761
|
throw new Error('--session option is only valid with --isolated');
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* - Real-time output capture
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
const { resolveChildExitCode } = require('./exit-reason');
|
|
15
|
+
|
|
14
16
|
// Debug mode from environment
|
|
15
17
|
const DEBUG =
|
|
16
18
|
process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
|
|
@@ -54,7 +56,7 @@ async function execCommand(command, options = {}) {
|
|
|
54
56
|
return {
|
|
55
57
|
stdout: (result.stdout || '').trim(),
|
|
56
58
|
stderr: (result.stderr || '').trim(),
|
|
57
|
-
code: result.code
|
|
59
|
+
code: resolveChildExitCode(result.code),
|
|
58
60
|
};
|
|
59
61
|
} catch (err) {
|
|
60
62
|
if (DEBUG) {
|
|
@@ -91,7 +93,7 @@ async function execCommandAsync(command, options = {}) {
|
|
|
91
93
|
return {
|
|
92
94
|
stdout: (result.stdout || '').trim(),
|
|
93
95
|
stderr: (result.stderr || '').trim(),
|
|
94
|
-
code: result.code
|
|
96
|
+
code: resolveChildExitCode(result.code),
|
|
95
97
|
};
|
|
96
98
|
} catch (err) {
|
|
97
99
|
if (DEBUG) {
|
|
@@ -34,6 +34,13 @@ const END_TIME_SOURCE = {
|
|
|
34
34
|
OBSERVED_AT: 'observed-at',
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* `exitReason` of a record whose container exit the watcher never observed:
|
|
39
|
+
* docker had no `FinishedAt` for it, so neither the exit code nor the end time
|
|
40
|
+
* is a fact (issue #174).
|
|
41
|
+
*/
|
|
42
|
+
const WATCHER_LOST_CONTAINER = 'watcher-lost-container';
|
|
43
|
+
|
|
37
44
|
/**
|
|
38
45
|
* Persist the terminal state of a detached execution.
|
|
39
46
|
*
|
|
@@ -49,6 +56,7 @@ const END_TIME_SOURCE = {
|
|
|
49
56
|
* @param {string|null} [options.startedAt] - `.State.StartedAt`
|
|
50
57
|
* @param {string|null} [options.finishedAt] - `.State.FinishedAt`
|
|
51
58
|
* @param {string|null} [options.containerError] - `.State.Error`
|
|
59
|
+
* @param {boolean|string|null} [options.running] - `.State.Running`
|
|
52
60
|
* @returns {{updated: boolean, reason?: string, record?: Object}} Outcome
|
|
53
61
|
*/
|
|
54
62
|
function finalizeDetachedExecution(options = {}) {
|
|
@@ -56,6 +64,11 @@ function finalizeDetachedExecution(options = {}) {
|
|
|
56
64
|
if (!store || !executionId) {
|
|
57
65
|
return { updated: false, reason: 'missing-arguments' };
|
|
58
66
|
}
|
|
67
|
+
if (normalizeBoolean(options.running) === true) {
|
|
68
|
+
// A running container has not finished: its record stays `executing`, and
|
|
69
|
+
// `--status` keeps probing the live container (issue #174).
|
|
70
|
+
return { updated: false, reason: 'still-running' };
|
|
71
|
+
}
|
|
59
72
|
|
|
60
73
|
let record;
|
|
61
74
|
try {
|
|
@@ -72,15 +85,22 @@ function finalizeDetachedExecution(options = {}) {
|
|
|
72
85
|
return { updated: false, reason: 'already-final', record };
|
|
73
86
|
}
|
|
74
87
|
|
|
88
|
+
const finishedAt = normalizeDockerTimestamp(options.finishedAt);
|
|
75
89
|
const described = describeExitCode(options.exitCode);
|
|
90
|
+
// Without a `FinishedAt`, `ExitCode=0` is Docker's zero value for a container
|
|
91
|
+
// that has not finished — never an observed success (issue #174).
|
|
92
|
+
const exitCode = described.code === 0 && !finishedAt ? null : described.code;
|
|
76
93
|
record.status = 'executed';
|
|
77
|
-
if (
|
|
78
|
-
record.exitCode =
|
|
79
|
-
} else if (
|
|
94
|
+
if (exitCode !== null) {
|
|
95
|
+
record.exitCode = exitCode;
|
|
96
|
+
} else if (
|
|
97
|
+
record.exitCode === null ||
|
|
98
|
+
record.exitCode === undefined ||
|
|
99
|
+
!finishedAt
|
|
100
|
+
) {
|
|
80
101
|
record.exitCode = -1;
|
|
81
102
|
}
|
|
82
103
|
|
|
83
|
-
const finishedAt = normalizeDockerTimestamp(options.finishedAt);
|
|
84
104
|
if (finishedAt) {
|
|
85
105
|
record.endTime = new Date(finishedAt).toISOString();
|
|
86
106
|
record.endTimeSource = END_TIME_SOURCE.DOCKER_FINISHED_AT;
|
|
@@ -103,7 +123,8 @@ function finalizeDetachedExecution(options = {}) {
|
|
|
103
123
|
record.oomKilled = oomKilled;
|
|
104
124
|
}
|
|
105
125
|
|
|
106
|
-
const exitReason =
|
|
126
|
+
const exitReason =
|
|
127
|
+
resolveReason(record) || (finishedAt ? null : WATCHER_LOST_CONTAINER);
|
|
107
128
|
if (exitReason) {
|
|
108
129
|
record.exitReason = exitReason;
|
|
109
130
|
}
|
|
@@ -222,14 +243,14 @@ function buildDetachedFinalizeSnippet(executionId) {
|
|
|
222
243
|
return (
|
|
223
244
|
`${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
|
|
224
245
|
`${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
|
|
225
|
-
`"$${v.started}" "$${v.finished}" "$${v.error}" ` +
|
|
246
|
+
`"$${v.started}" "$${v.finished}" "$${v.error}" "$${v.running}" ` +
|
|
226
247
|
`>/dev/null 2>&1 || true`
|
|
227
248
|
);
|
|
228
249
|
}
|
|
229
250
|
|
|
230
251
|
/**
|
|
231
252
|
* Entry point used by the detached watcher:
|
|
232
|
-
* <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error>
|
|
253
|
+
* <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error> <running>
|
|
233
254
|
* Always exits 0 — a bookkeeping failure must never turn into a visible error
|
|
234
255
|
* in a log the user is reading for the command's own output.
|
|
235
256
|
* @param {string[]} argv - Positional arguments
|
|
@@ -243,6 +264,7 @@ function main(argv) {
|
|
|
243
264
|
startedAt,
|
|
244
265
|
finishedAt,
|
|
245
266
|
containerError,
|
|
267
|
+
running,
|
|
246
268
|
] = argv;
|
|
247
269
|
if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
|
|
248
270
|
return;
|
|
@@ -262,6 +284,7 @@ function main(argv) {
|
|
|
262
284
|
startedAt,
|
|
263
285
|
finishedAt,
|
|
264
286
|
containerError,
|
|
287
|
+
running,
|
|
265
288
|
});
|
|
266
289
|
} catch {
|
|
267
290
|
// Deliberately silent: see the exit-0 contract above.
|
|
@@ -274,6 +297,7 @@ if (require.main === module) {
|
|
|
274
297
|
|
|
275
298
|
module.exports = {
|
|
276
299
|
END_TIME_SOURCE,
|
|
300
|
+
WATCHER_LOST_CONTAINER,
|
|
277
301
|
buildDetachedFinalizeSnippet,
|
|
278
302
|
finalizeDetachedExecution,
|
|
279
303
|
main,
|
|
@@ -11,10 +11,13 @@ const {
|
|
|
11
11
|
buildDockerPostMortemSnippet,
|
|
12
12
|
buildDockerRemovalNoteSnippet,
|
|
13
13
|
buildDockerStateSnippet,
|
|
14
|
+
buildDockerStillRunningNoteSnippet,
|
|
15
|
+
buildDockerWaitForExitSnippet,
|
|
14
16
|
DOCKER_STATE_INSPECT_FORMAT,
|
|
15
17
|
formatContainerPostMortem,
|
|
16
18
|
formatContainerRemovalNote,
|
|
17
19
|
normalizeDockerTimestamp,
|
|
20
|
+
SHELL_VARS,
|
|
18
21
|
} = require('./docker-post-mortem');
|
|
19
22
|
const { buildDetachedFinalizeSnippet } = require('./detached-finalize');
|
|
20
23
|
|
|
@@ -312,20 +315,38 @@ function buildSuccessfulNonOomCondition() {
|
|
|
312
315
|
* 3. it hands the same facts to the finalizer so the execution record stops
|
|
313
316
|
* being `executing` forever (issue #170.1).
|
|
314
317
|
*
|
|
318
|
+
* It only does any of that once the container has really exited: the return of
|
|
319
|
+
* `docker logs -f` / `docker wait` is not proof of exit (issue #174). A
|
|
320
|
+
* container that is somehow still running afterwards is never removed, gets no
|
|
321
|
+
* `Exit Code:` footer and is never finalized — its record stays `executing`.
|
|
322
|
+
*
|
|
323
|
+
* With `recoverOnKill` (issue #176), a killed main process (exit 137 or
|
|
324
|
+
* `OOMKilled`) is first handed to the recovery entry point. When it resumes
|
|
325
|
+
* the container, this watcher stops here — a new one follows the resumed run —
|
|
326
|
+
* and cleanup, footer and finalization are left to whichever watcher sees the
|
|
327
|
+
* last run end.
|
|
328
|
+
*
|
|
315
329
|
* @param {string} containerName - Docker container name
|
|
316
330
|
* @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
|
|
317
331
|
* @param {string|null} logPath - Log file to append to, or null
|
|
318
332
|
* @param {string|null} [executionId] - Execution UUID to finalize, or null
|
|
333
|
+
* @param {{since?: string, recoverOnKill?: boolean}} [watcherOptions] -
|
|
334
|
+
* `since` limits `docker logs` to output after a restart, so a resumed run
|
|
335
|
+
* does not copy the previous run's output into the log again
|
|
319
336
|
* @returns {string} The shell script
|
|
320
337
|
*/
|
|
321
338
|
function buildDetachedDockerCompletionScript(
|
|
322
339
|
containerName,
|
|
323
340
|
policy,
|
|
324
341
|
logPath,
|
|
325
|
-
executionId = null
|
|
342
|
+
executionId = null,
|
|
343
|
+
watcherOptions = {}
|
|
326
344
|
) {
|
|
327
345
|
const quotedName = shellQuote(containerName);
|
|
328
346
|
const parts = [];
|
|
347
|
+
// Everything that assumes the container has exited: cleanup, footer and
|
|
348
|
+
// finalization. Guarded as a whole by `.State.Running` below.
|
|
349
|
+
const exited = [];
|
|
329
350
|
|
|
330
351
|
if (logPath) {
|
|
331
352
|
const quotedLogPath = shellQuote(logPath);
|
|
@@ -340,34 +361,39 @@ function buildDetachedDockerCompletionScript(
|
|
|
340
361
|
const remove = `docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; ${removalNote}`;
|
|
341
362
|
const keep = `${postMortem}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
|
|
342
363
|
|
|
343
|
-
|
|
364
|
+
const since = watcherOptions.since
|
|
365
|
+
? ` --since ${shellQuote(watcherOptions.since)}`
|
|
366
|
+
: '';
|
|
367
|
+
parts.push(`docker logs -f${since} ${quotedName} >> ${quotedLogPath} 2>&1`);
|
|
368
|
+
parts.push(buildDockerWaitForExitSnippet(containerName, quotedLogPath));
|
|
344
369
|
parts.push(buildDockerStateSnippet(containerName));
|
|
345
370
|
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
346
|
-
|
|
371
|
+
exited.push(remove);
|
|
347
372
|
} else if (
|
|
348
373
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
|
|
349
374
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
|
|
350
375
|
) {
|
|
351
|
-
|
|
376
|
+
exited.push(
|
|
352
377
|
`if ${buildSuccessfulNonOomCondition()}; then ${remove}; else ${keep}; fi`
|
|
353
378
|
);
|
|
354
379
|
} else {
|
|
355
380
|
// KEEP: the container is never removed, so the log used to end with the
|
|
356
381
|
// raw command output and nothing else. The post-mortem is exactly the
|
|
357
382
|
// information an operator needs before deciding what to do with it.
|
|
358
|
-
|
|
383
|
+
exited.push(keep);
|
|
359
384
|
}
|
|
360
|
-
|
|
385
|
+
exited.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
|
|
361
386
|
} else {
|
|
362
387
|
parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
|
|
388
|
+
parts.push(buildDockerWaitForExitSnippet(containerName));
|
|
363
389
|
parts.push(buildDockerStateSnippet(containerName));
|
|
364
390
|
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
365
|
-
|
|
391
|
+
exited.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
|
|
366
392
|
} else if (
|
|
367
393
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
|
|
368
394
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
|
|
369
395
|
) {
|
|
370
|
-
|
|
396
|
+
exited.push(
|
|
371
397
|
`if ${buildSuccessfulNonOomCondition()}; then docker rm -f ${quotedName} >/dev/null 2>&1 || true; fi`
|
|
372
398
|
);
|
|
373
399
|
}
|
|
@@ -375,9 +401,23 @@ function buildDetachedDockerCompletionScript(
|
|
|
375
401
|
|
|
376
402
|
if (executionId) {
|
|
377
403
|
// Last, so the record is only marked terminal once the log is complete.
|
|
378
|
-
|
|
404
|
+
exited.push(buildDetachedFinalizeSnippet(executionId));
|
|
379
405
|
}
|
|
380
406
|
|
|
407
|
+
const stillRunning = logPath
|
|
408
|
+
? buildDockerStillRunningNoteSnippet(containerName, shellQuote(logPath))
|
|
409
|
+
: ':';
|
|
410
|
+
let recovery = '';
|
|
411
|
+
if (executionId && watcherOptions.recoverOnKill) {
|
|
412
|
+
// Lazily required: the recovery module starts watchers itself.
|
|
413
|
+
const { buildRecoverySnippet } = require('./execution-recovery');
|
|
414
|
+
recovery = `elif ${buildRecoverySnippet(executionId)}; then :; `;
|
|
415
|
+
}
|
|
416
|
+
parts.push(
|
|
417
|
+
`if [ "$${SHELL_VARS.running}" = true ]; then ${stillRunning}; ` +
|
|
418
|
+
`${recovery}else ${exited.length ? exited.join('; ') : ':'}; fi`
|
|
419
|
+
);
|
|
420
|
+
|
|
381
421
|
return parts.join('; ');
|
|
382
422
|
}
|
|
383
423
|
|
|
@@ -387,13 +427,16 @@ function buildDetachedDockerCompletionScript(
|
|
|
387
427
|
* @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
|
|
388
428
|
* @param {string|null} logPath - Log file to append to, or null
|
|
389
429
|
* @param {string|null} [executionId] - Execution UUID to finalize, or null
|
|
430
|
+
* @param {{since?: string, recoverOnKill?: boolean}} [watcherOptions] - See
|
|
431
|
+
* `buildDetachedDockerCompletionScript`
|
|
390
432
|
* @returns {void}
|
|
391
433
|
*/
|
|
392
434
|
function startDetachedDockerCompletionWatcher(
|
|
393
435
|
containerName,
|
|
394
436
|
policy,
|
|
395
437
|
logPath,
|
|
396
|
-
executionId = null
|
|
438
|
+
executionId = null,
|
|
439
|
+
watcherOptions = {}
|
|
397
440
|
) {
|
|
398
441
|
const watcher = spawn(
|
|
399
442
|
'sh',
|
|
@@ -403,7 +446,8 @@ function startDetachedDockerCompletionWatcher(
|
|
|
403
446
|
containerName,
|
|
404
447
|
policy,
|
|
405
448
|
logPath,
|
|
406
|
-
executionId
|
|
449
|
+
executionId,
|
|
450
|
+
watcherOptions
|
|
407
451
|
),
|
|
408
452
|
],
|
|
409
453
|
{
|
|
@@ -138,15 +138,83 @@ const SHELL_VARS = {
|
|
|
138
138
|
signal: '__start_command_signal',
|
|
139
139
|
exitText: '__start_command_exit_text',
|
|
140
140
|
lifetime: '__start_command_lifetime',
|
|
141
|
+
running: '__start_command_running',
|
|
141
142
|
};
|
|
142
143
|
|
|
143
144
|
/** `docker inspect -f` template collecting every post-mortem fact at once. */
|
|
144
145
|
const DOCKER_STATE_INSPECT_FORMAT =
|
|
145
146
|
'{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.StartedAt}} {{.State.FinishedAt}}';
|
|
146
147
|
|
|
148
|
+
/** `docker inspect -f` template answering "is the container still running?". */
|
|
149
|
+
const DOCKER_RUNNING_INSPECT_FORMAT = '{{.State.Running}}';
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Written into the log when `docker logs -f` returned while the container was
|
|
153
|
+
* still running (issue #174).
|
|
154
|
+
*/
|
|
155
|
+
const LOG_CAPTURE_STOPPED_NOTE =
|
|
156
|
+
'Log capture stopped early: docker logs -f returned while the container ' +
|
|
157
|
+
'was still running (a failed write to this log such as ENOSPC, or a dockerd ' +
|
|
158
|
+
'restart). Waiting for the container to exit.';
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Written into the log when the watcher gave up while the container was still
|
|
162
|
+
* running. No footer follows: an `Exit Code:` line would be a fabrication.
|
|
163
|
+
*/
|
|
164
|
+
const STILL_RUNNING_NOTE =
|
|
165
|
+
'The watcher stopped observing it before it exited, so it was not removed ' +
|
|
166
|
+
'and the execution record stays executing.';
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Shell condition that holds while the container is running.
|
|
170
|
+
* @param {string} quotedName - Already shell-quoted container name
|
|
171
|
+
* @returns {string} Shell condition
|
|
172
|
+
*/
|
|
173
|
+
function buildDockerRunningCondition(quotedName) {
|
|
174
|
+
return `[ "$(docker inspect -f '${DOCKER_RUNNING_INSPECT_FORMAT}' ${quotedName} 2>/dev/null)" = true ]`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Shell fragment blocking until the container has really exited.
|
|
179
|
+
*
|
|
180
|
+
* `docker logs -f` and `docker wait` both return early when the *stream* ends
|
|
181
|
+
* rather than the container: `docker logs -f C >> LOG` returns as soon as its
|
|
182
|
+
* own write to LOG fails (ENOSPC, when the container filled the disk the log
|
|
183
|
+
* lives on), and both return when dockerd restarts under live-restore while the
|
|
184
|
+
* container keeps running. The watcher used to take that return as the
|
|
185
|
+
* container's exit, inspect a *running* container — Docker reports its zero
|
|
186
|
+
* values `ExitCode=0 FinishedAt=0001-01-01T00:00:00Z` — and `docker rm -f` the
|
|
187
|
+
* live workload as a success (issue #174). So the fragment loops on
|
|
188
|
+
* `.State.Running`, not on the return of the command it waited on; `sleep 1`
|
|
189
|
+
* keeps a failing `docker wait` from spinning.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} containerName - Container to wait for
|
|
192
|
+
* @param {string|null} [quotedLogPath] - Already shell-quoted log path, to
|
|
193
|
+
* record that log capture stopped early; null when there is no log
|
|
194
|
+
* @returns {string} Shell command
|
|
195
|
+
*/
|
|
196
|
+
function buildDockerWaitForExitSnippet(containerName, quotedLogPath = null) {
|
|
197
|
+
const quotedName = shellQuote(containerName);
|
|
198
|
+
const running = buildDockerRunningCondition(quotedName);
|
|
199
|
+
const parts = [];
|
|
200
|
+
if (quotedLogPath) {
|
|
201
|
+
parts.push(
|
|
202
|
+
`if ${running}; then printf '\\n%s\\n' ${shellQuote(LOG_CAPTURE_STOPPED_NOTE)} >> ${quotedLogPath} 2>/dev/null; fi`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
parts.push(
|
|
206
|
+
`while ${running}; do docker wait ${quotedName} >/dev/null 2>&1 || sleep 1; done`
|
|
207
|
+
);
|
|
208
|
+
return parts.join('; ');
|
|
209
|
+
}
|
|
210
|
+
|
|
147
211
|
/**
|
|
148
212
|
* Shell fragment reading the container's terminal state.
|
|
149
213
|
*
|
|
214
|
+
* Also records `.State.Running` in `$__start_command_running`, so the caller
|
|
215
|
+
* can refuse to remove or finalize a container that is still alive
|
|
216
|
+
* (issue #174).
|
|
217
|
+
*
|
|
150
218
|
* The four whitespace-free fields are split with `set --` rather than the
|
|
151
219
|
* `${var%% *}` / `${var##* }` pair the two-field version used: those only ever
|
|
152
220
|
* reach the first and the last field. `State.Error` is read separately because
|
|
@@ -168,6 +236,10 @@ function buildDockerStateSnippet(containerName) {
|
|
|
168
236
|
`${v.error}=$(docker inspect -f '{{.State.Error}}' ${quotedName} 2>/dev/null)`,
|
|
169
237
|
`case "$${v.started}" in ${DOCKER_ZERO_TIME}|'') ${v.started}=${UNKNOWN};; esac`,
|
|
170
238
|
`case "$${v.finished}" in ${DOCKER_ZERO_TIME}|'') ${v.finished}=${UNKNOWN};; esac`,
|
|
239
|
+
// `ExitCode=0` next to a zero `FinishedAt` is Docker's zero value for a
|
|
240
|
+
// container that has not finished, never an observed exit 0 (issue #174).
|
|
241
|
+
`if [ "$${v.finished}" = ${UNKNOWN} ] && [ "$${v.exit}" = 0 ]; then ${v.exit}=-1; fi`,
|
|
242
|
+
`${v.running}=$(docker inspect -f '${DOCKER_RUNNING_INSPECT_FORMAT}' ${quotedName} 2>/dev/null)`,
|
|
171
243
|
buildSignalDecodeSnippet(),
|
|
172
244
|
buildLifetimeSnippet(),
|
|
173
245
|
].join('; ');
|
|
@@ -252,6 +324,21 @@ function buildDockerPostMortemSnippet(containerName, quotedLogPath) {
|
|
|
252
324
|
);
|
|
253
325
|
}
|
|
254
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Shell fragment appending the note for a container the watcher lost while it
|
|
329
|
+
* was still running (issue #174).
|
|
330
|
+
* @param {string} containerName - Container that is still running
|
|
331
|
+
* @param {string} quotedLogPath - Already shell-quoted log path
|
|
332
|
+
* @returns {string} Shell command
|
|
333
|
+
*/
|
|
334
|
+
function buildDockerStillRunningNoteSnippet(containerName, quotedLogPath) {
|
|
335
|
+
const quotedName = shellQuote(containerName);
|
|
336
|
+
return (
|
|
337
|
+
`printf '\\nContainer still running: %s\\n%s\\nCheck it with: docker inspect %s\\n' ` +
|
|
338
|
+
`${quotedName} ${shellQuote(STILL_RUNNING_NOTE)} ${quotedName} >> ${quotedLogPath}`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
255
342
|
/**
|
|
256
343
|
* Shell fragment appending the one-line note for a removed container.
|
|
257
344
|
* @param {string} containerName - Container that was removed
|
|
@@ -270,15 +357,21 @@ function buildDockerRemovalNoteSnippet(containerName, quotedLogPath) {
|
|
|
270
357
|
}
|
|
271
358
|
|
|
272
359
|
module.exports = {
|
|
360
|
+
DOCKER_RUNNING_INSPECT_FORMAT,
|
|
273
361
|
DOCKER_STATE_INSPECT_FORMAT,
|
|
274
362
|
DOCKER_ZERO_TIME,
|
|
363
|
+
LOG_CAPTURE_STOPPED_NOTE,
|
|
364
|
+
STILL_RUNNING_NOTE,
|
|
275
365
|
NO_ERROR,
|
|
276
366
|
POST_MORTEM_HEADER,
|
|
277
367
|
SHELL_VARS,
|
|
278
368
|
UNKNOWN,
|
|
279
369
|
buildDockerPostMortemSnippet,
|
|
280
370
|
buildDockerRemovalNoteSnippet,
|
|
371
|
+
buildDockerRunningCondition,
|
|
281
372
|
buildDockerStateSnippet,
|
|
373
|
+
buildDockerStillRunningNoteSnippet,
|
|
374
|
+
buildDockerWaitForExitSnippet,
|
|
282
375
|
buildLifetimeSnippet,
|
|
283
376
|
buildSignalDecodeSnippet,
|
|
284
377
|
describeExitCode,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse and validate the launch-time recovery options (issue #176):
|
|
3
|
+
* --on-kill-resume <N> resume up to N times when the main process is
|
|
4
|
+
* killed (exit 137 / OOMKilled)
|
|
5
|
+
* --recovery-command <cmd> command to run in the same container on resume
|
|
6
|
+
* (without it, the original command is re-run)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
function parseOnKillResumeValue(value) {
|
|
10
|
+
const text = String(value).trim();
|
|
11
|
+
const count = Number(text);
|
|
12
|
+
if (!/^\d+$/.test(text) || !Number.isSafeInteger(count) || count < 1) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`Invalid --on-kill-resume value: "${value}". Expected a positive integer.`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return count;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseDockerRecoveryOption(args, index, options) {
|
|
21
|
+
const arg = args[index];
|
|
22
|
+
if (arg === '--on-kill-resume' || arg === '--recovery-command') {
|
|
23
|
+
if (index + 1 >= args.length || args[index + 1].startsWith('-')) {
|
|
24
|
+
const value = arg === '--on-kill-resume' ? 'count' : 'command';
|
|
25
|
+
throw new Error(`Option ${arg} requires a ${value} argument`);
|
|
26
|
+
}
|
|
27
|
+
if (arg === '--on-kill-resume') {
|
|
28
|
+
options.onKillResume = parseOnKillResumeValue(args[index + 1]);
|
|
29
|
+
} else {
|
|
30
|
+
options.recoveryCommand = args[index + 1];
|
|
31
|
+
}
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
if (arg.startsWith('--on-kill-resume=')) {
|
|
35
|
+
options.onKillResume = parseOnKillResumeValue(
|
|
36
|
+
arg.slice('--on-kill-resume='.length)
|
|
37
|
+
);
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
if (arg.startsWith('--recovery-command=')) {
|
|
41
|
+
options.recoveryCommand = arg.slice('--recovery-command='.length);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Recovery is driven by the detached docker completion watcher, so it needs a
|
|
49
|
+
* detached session whose only isolation level is docker. A recovery command on
|
|
50
|
+
* its own implies one attempt.
|
|
51
|
+
* @param {object} options - Parsed options
|
|
52
|
+
* @throws {Error} If the recovery options are used where they cannot work
|
|
53
|
+
*/
|
|
54
|
+
function validateDockerRecoveryOptions(options) {
|
|
55
|
+
if (
|
|
56
|
+
options.recoveryCommand !== null &&
|
|
57
|
+
options.recoveryCommand !== undefined
|
|
58
|
+
) {
|
|
59
|
+
if (String(options.recoveryCommand).trim() === '') {
|
|
60
|
+
throw new Error('--recovery-command requires a non-empty command');
|
|
61
|
+
}
|
|
62
|
+
options.onKillResume ??= 1;
|
|
63
|
+
}
|
|
64
|
+
if (!options.onKillResume) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const flag = options.recoveryCommand
|
|
68
|
+
? '--recovery-command'
|
|
69
|
+
: '--on-kill-resume';
|
|
70
|
+
const stack = options.isolatedStack || [options.isolated];
|
|
71
|
+
if (!options.isolated || stack.length !== 1 || stack[0] !== 'docker') {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`${flag} option is only valid with --isolated docker as the only isolation level`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (!options.detached) {
|
|
77
|
+
throw new Error(`${flag} option requires --detached`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
parse: parseDockerRecoveryOption,
|
|
83
|
+
validate: validateDockerRecoveryOptions,
|
|
84
|
+
};
|