start-command 0.32.1 → 0.34.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 +27 -0
- package/README.md +3 -0
- package/eslint.config.mjs +8 -0
- package/package.json +7 -3
- package/src/bin/cli.js +32 -137
- package/src/lib/args-parser-queries.js +228 -0
- package/src/lib/args-parser.js +38 -179
- package/src/lib/detached-finalize.js +281 -0
- package/src/lib/docker-cleanup.js +240 -39
- package/src/lib/docker-post-mortem.js +289 -0
- package/src/lib/execution-attach.js +245 -0
- package/src/lib/execution-resume-all.js +211 -0
- package/src/lib/execution-resume.js +455 -0
- package/src/lib/execution-store.js +93 -6
- package/src/lib/exit-reason.js +264 -0
- package/src/lib/failure-handler.js +43 -41
- package/src/lib/isolation-log-utils.js +66 -1
- package/src/lib/isolation.js +28 -15
- package/src/lib/query-commands.js +202 -0
- package/src/lib/screen-isolation.js +2 -1
- package/src/lib/session-probe.js +121 -0
- package/src/lib/shell-utils.js +164 -10
- package/src/lib/status-formatter.js +233 -69
- package/src/lib/usage.js +13 -1
- package/src/lib/user-manager.js +18 -1
- package/test/args-parser-attach-resume.js +159 -0
- package/test/args-parser-control.js +3 -3
- package/test/args-parser.js +1 -1
- package/test/check-file-size.js +91 -0
- package/test/ci-workflow-invariants.js +191 -0
- package/test/echo-integration.js +20 -13
- package/test/execution-attach.js +221 -0
- package/test/execution-resume.js +433 -0
- package/test/execution-store.js +57 -2
- package/test/exit-reason.js +209 -0
- package/test/failure-handler.js +107 -0
- package/test/load-command-stream.mjs +83 -0
- package/test/regression-162.js +320 -0
- package/test/regression-164.js +308 -0
- package/test/regression-165.js +310 -0
- package/test/regression-170.js +321 -0
- package/test/regression-171.js +562 -0
- package/test/regression-84.js +8 -3
- package/test/release-name.mjs +57 -0
- package/test/session-probe.js +150 -0
- package/test/user-manager.js +23 -0
|
@@ -2,8 +2,21 @@ const { spawn, spawnSync } = require('child_process');
|
|
|
2
2
|
const {
|
|
3
3
|
appendLogFile,
|
|
4
4
|
createShellLogFooterSnippet,
|
|
5
|
+
FATAL_MARKER_TAIL_BYTES,
|
|
6
|
+
readLogTail,
|
|
5
7
|
shellQuote,
|
|
6
8
|
} = require('./isolation-log-utils');
|
|
9
|
+
const { resolveMemoryExhaustion } = require('./exit-reason');
|
|
10
|
+
const {
|
|
11
|
+
buildDockerPostMortemSnippet,
|
|
12
|
+
buildDockerRemovalNoteSnippet,
|
|
13
|
+
buildDockerStateSnippet,
|
|
14
|
+
DOCKER_STATE_INSPECT_FORMAT,
|
|
15
|
+
formatContainerPostMortem,
|
|
16
|
+
formatContainerRemovalNote,
|
|
17
|
+
normalizeDockerTimestamp,
|
|
18
|
+
} = require('./docker-post-mortem');
|
|
19
|
+
const { buildDetachedFinalizeSnippet } = require('./detached-finalize');
|
|
7
20
|
|
|
8
21
|
const DOCKER_CONTAINER_CLEANUP_POLICY = {
|
|
9
22
|
DEFAULT: 'default',
|
|
@@ -56,7 +69,9 @@ function shouldCleanupDockerContainer(policy, exitCode, oomKilled = false) {
|
|
|
56
69
|
function getDockerContainerCleanupInstructions(containerName) {
|
|
57
70
|
return [
|
|
58
71
|
`Container kept for investigation: ${containerName}`,
|
|
59
|
-
`
|
|
72
|
+
`Re-enter while running: $ --attach ${containerName}`,
|
|
73
|
+
`Continue the stored command: $ --resume ${containerName}`,
|
|
74
|
+
`Run another command in the same container: $ --resume ${containerName} -- <command>`,
|
|
60
75
|
`Remove when done: docker rm -f ${containerName}`,
|
|
61
76
|
].join('\n');
|
|
62
77
|
}
|
|
@@ -86,27 +101,85 @@ function appendDockerContainerCleanupPolicyMessage(
|
|
|
86
101
|
return `${message}\nContainer will be removed after command completes.`;
|
|
87
102
|
}
|
|
88
103
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Read a finished container's post-mortem facts in a single `docker inspect`.
|
|
106
|
+
*
|
|
107
|
+
* The attached path only ever inspected `State.OOMKilled`, which left its
|
|
108
|
+
* "Container kept for investigation" message unable to say *why* the container
|
|
109
|
+
* died — the same gap the detached watcher had (issue #171.1). Reading the
|
|
110
|
+
* facts once, before any `docker rm`, also makes them available to the removal
|
|
111
|
+
* path, where the container is about to stop existing (issue #171.3).
|
|
112
|
+
*
|
|
113
|
+
* @param {string} containerName - Container to inspect
|
|
114
|
+
* @returns {object|null} Facts accepted by the `docker-post-mortem` formatters,
|
|
115
|
+
* or null when the container could not be inspected at all
|
|
116
|
+
*/
|
|
117
|
+
function readDockerContainerState(containerName) {
|
|
118
|
+
const inspect = (format) => {
|
|
119
|
+
const result = spawnSync(
|
|
120
|
+
getDockerCommand(),
|
|
121
|
+
['inspect', '-f', format, containerName],
|
|
122
|
+
getDockerSpawnOptions({
|
|
123
|
+
encoding: 'utf8',
|
|
124
|
+
env: process.env,
|
|
125
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
if (result.error || result.status !== 0) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return String(result.stdout || '').trim();
|
|
132
|
+
};
|
|
133
|
+
const state = inspect(DOCKER_STATE_INSPECT_FORMAT);
|
|
134
|
+
if (state === null) {
|
|
100
135
|
return null;
|
|
101
136
|
}
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
137
|
+
const [exitCode, oomKilled, startedAt, finishedAt] = state.split(/\s+/);
|
|
138
|
+
// `State.Error` is free-form text that may contain spaces, so it cannot ride
|
|
139
|
+
// along in the whitespace-separated template above.
|
|
140
|
+
const error = inspect('{{.State.Error}}');
|
|
141
|
+
const parsedExit = Number.parseInt(exitCode, 10);
|
|
142
|
+
return {
|
|
143
|
+
containerName,
|
|
144
|
+
exitCode: Number.isFinite(parsedExit) ? parsedExit : null,
|
|
145
|
+
oomKilled:
|
|
146
|
+
oomKilled === 'true' ? true : oomKilled === 'false' ? false : null,
|
|
147
|
+
startedAt: normalizeDockerTimestamp(startedAt),
|
|
148
|
+
finishedAt: normalizeDockerTimestamp(finishedAt),
|
|
149
|
+
error: error || null,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Write the post-mortem for an attached run into its log and return the same
|
|
155
|
+
* text for the console message.
|
|
156
|
+
*
|
|
157
|
+
* Kept and removed containers both get their facts recorded — a kept container
|
|
158
|
+
* gets the full block, a removed one the single line — so the log of an
|
|
159
|
+
* attached run carries exactly what the detached watcher writes (issues #171.2
|
|
160
|
+
* and #171.3).
|
|
161
|
+
*
|
|
162
|
+
* @param {object} params - Container name, inspected state, log path, and
|
|
163
|
+
* whether the container was removed
|
|
164
|
+
* @returns {string} Message lines to append, or '' when nothing is known
|
|
165
|
+
*/
|
|
166
|
+
function recordAttachedDockerPostMortem({
|
|
167
|
+
containerName,
|
|
168
|
+
state,
|
|
169
|
+
logPath = null,
|
|
170
|
+
removed = false,
|
|
171
|
+
}) {
|
|
172
|
+
if (!state) {
|
|
173
|
+
return '';
|
|
105
174
|
}
|
|
106
|
-
|
|
107
|
-
|
|
175
|
+
const facts = { ...state, containerName };
|
|
176
|
+
const text = removed
|
|
177
|
+
? formatContainerRemovalNote(facts)
|
|
178
|
+
: formatContainerPostMortem(facts);
|
|
179
|
+
if (logPath) {
|
|
180
|
+
appendLogFile(logPath, `\n${text}`);
|
|
108
181
|
}
|
|
109
|
-
return
|
|
182
|
+
return `\n${text.trimEnd()}`;
|
|
110
183
|
}
|
|
111
184
|
|
|
112
185
|
function readDockerContainerStatus(containerName) {
|
|
@@ -142,14 +215,80 @@ function removeDockerContainer(containerName, logPath = null) {
|
|
|
142
215
|
return !result.error && result.status === 0;
|
|
143
216
|
}
|
|
144
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Exit codes of a process that aborted itself: SIGABRT (134) and SIGSEGV (139).
|
|
220
|
+
* These are exactly the codes a runtime produces when it dies on its own memory
|
|
221
|
+
* limit — Node/V8 prints `FATAL ERROR: Reached heap limit ...` and aborts — long
|
|
222
|
+
* before the container limit is reached, so the kernel never OOM-kills anything
|
|
223
|
+
* and `State.OOMKilled` stays `false` (issue #165).
|
|
224
|
+
*/
|
|
225
|
+
const SELF_ABORT_EXIT_CODES = [134, 139];
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Appended to the kept-container reason for a self-abort exit code, so the
|
|
229
|
+
* footer stops asserting the opposite of the `FATAL ERROR` printed a few lines
|
|
230
|
+
* above it. The footer is the string downstream tooling greps.
|
|
231
|
+
*/
|
|
232
|
+
const OOM_FLAG_BLIND_NOTE =
|
|
233
|
+
'a runtime self-abort on its own memory limit is invisible to this flag - ' +
|
|
234
|
+
'check the log above for a fatal memory marker';
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Shell fragment computing `$__start_command_reason` for the kept footer.
|
|
238
|
+
* @returns {string} Shell command
|
|
239
|
+
*/
|
|
240
|
+
function buildDockerKeptReasonSnippet() {
|
|
241
|
+
return (
|
|
242
|
+
'__start_command_reason="exitCode=$__start_command_exit oomKilled=$__start_command_oom"; ' +
|
|
243
|
+
`case "$__start_command_exit" in ${SELF_ABORT_EXIT_CODES.join('|')}) ` +
|
|
244
|
+
'[ "$__start_command_oom" = true ] || ' +
|
|
245
|
+
`__start_command_reason="$__start_command_reason (${OOM_FLAG_BLIND_NOTE})";; ` +
|
|
246
|
+
'esac'
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Lines appended to an attached session's message when the container is kept
|
|
252
|
+
* because the command failed. A runtime that aborts on its own memory limit
|
|
253
|
+
* never trips `State.OOMKilled`, so a bare `oomKilled false` would contradict
|
|
254
|
+
* the `FATAL ERROR` the runtime just printed into this very log (issue #165).
|
|
255
|
+
* Best effort: the tail is read right after the child exits.
|
|
256
|
+
*
|
|
257
|
+
* @param {object} params - Container name, exit code, OOM flag and log path
|
|
258
|
+
* @returns {string} Message lines to append
|
|
259
|
+
*/
|
|
260
|
+
function buildAttachedDockerKeptMessage({
|
|
261
|
+
containerName,
|
|
262
|
+
exitCode,
|
|
263
|
+
oomKilled,
|
|
264
|
+
logPath,
|
|
265
|
+
}) {
|
|
266
|
+
let message =
|
|
267
|
+
oomKilled === true
|
|
268
|
+
? `\nContainer kept because Docker reports it was OOM-killed.`
|
|
269
|
+
: `\nContainer kept because the command failed.`;
|
|
270
|
+
const memory = resolveMemoryExhaustion({
|
|
271
|
+
exitCode,
|
|
272
|
+
logTail: logPath ? readLogTail(logPath, FATAL_MARKER_TAIL_BYTES) : null,
|
|
273
|
+
oomKilled,
|
|
274
|
+
});
|
|
275
|
+
if (memory) {
|
|
276
|
+
message += `\nMemory exhaustion detected in the log: ${memory.memoryExhaustedReason}`;
|
|
277
|
+
}
|
|
278
|
+
return `${message}\nRemove when done: docker rm -f ${containerName}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
145
281
|
function buildDockerKeptLogSnippet(containerName, quotedLogPath) {
|
|
146
282
|
const quotedName = shellQuote(containerName);
|
|
147
283
|
return (
|
|
148
|
-
|
|
149
|
-
`
|
|
284
|
+
`${buildDockerKeptReasonSnippet()}; ` +
|
|
285
|
+
`printf '\\nContainer kept for investigation: %s\\nReason: %s\\n` +
|
|
286
|
+
`Re-enter while running: $ --attach %s\\n` +
|
|
287
|
+
`Continue the stored command: $ --resume %s\\n` +
|
|
288
|
+
`Run another command in the same container: $ --resume %s -- <command>\\n` +
|
|
150
289
|
`Remove when done: docker rm -f %s\\n' ` +
|
|
151
|
-
`${quotedName} "$
|
|
152
|
-
`${quotedName} ${quotedName} ${quotedName} >> ${quotedLogPath}`
|
|
290
|
+
`${quotedName} "$__start_command_reason" ` +
|
|
291
|
+
`${quotedName} ${quotedName} ${quotedName} ${quotedName} >> ${quotedLogPath}`
|
|
153
292
|
);
|
|
154
293
|
}
|
|
155
294
|
|
|
@@ -160,37 +299,68 @@ function buildSuccessfulNonOomCondition() {
|
|
|
160
299
|
);
|
|
161
300
|
}
|
|
162
301
|
|
|
163
|
-
|
|
302
|
+
/**
|
|
303
|
+
* Build the POSIX shell run by the detached completion watcher.
|
|
304
|
+
*
|
|
305
|
+
* The watcher is the only observer left once `start --detached` returns, so it
|
|
306
|
+
* carries three responsibilities that used to be missing or deferred:
|
|
307
|
+
* 1. it reads the container's post-mortem facts out of a single
|
|
308
|
+
* `docker inspect` (issue #171.1);
|
|
309
|
+
* 2. it writes those facts into the log on every path — kept *and* removed —
|
|
310
|
+
* so an operator reading the log never has to reconstruct them from a
|
|
311
|
+
* container that no longer exists (issues #171.2, #171.3);
|
|
312
|
+
* 3. it hands the same facts to the finalizer so the execution record stops
|
|
313
|
+
* being `executing` forever (issue #170.1).
|
|
314
|
+
*
|
|
315
|
+
* @param {string} containerName - Docker container name
|
|
316
|
+
* @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
|
|
317
|
+
* @param {string|null} logPath - Log file to append to, or null
|
|
318
|
+
* @param {string|null} [executionId] - Execution UUID to finalize, or null
|
|
319
|
+
* @returns {string} The shell script
|
|
320
|
+
*/
|
|
321
|
+
function buildDetachedDockerCompletionScript(
|
|
322
|
+
containerName,
|
|
323
|
+
policy,
|
|
324
|
+
logPath,
|
|
325
|
+
executionId = null
|
|
326
|
+
) {
|
|
164
327
|
const quotedName = shellQuote(containerName);
|
|
165
328
|
const parts = [];
|
|
166
329
|
|
|
167
330
|
if (logPath) {
|
|
168
331
|
const quotedLogPath = shellQuote(logPath);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
332
|
+
const removalNote = buildDockerRemovalNoteSnippet(
|
|
333
|
+
containerName,
|
|
334
|
+
quotedLogPath
|
|
335
|
+
);
|
|
336
|
+
const postMortem = buildDockerPostMortemSnippet(
|
|
337
|
+
containerName,
|
|
338
|
+
quotedLogPath
|
|
172
339
|
);
|
|
173
|
-
|
|
174
|
-
|
|
340
|
+
const remove = `docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; ${removalNote}`;
|
|
341
|
+
const keep = `${postMortem}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
|
|
342
|
+
|
|
343
|
+
parts.push(`docker logs -f ${quotedName} >> ${quotedLogPath} 2>&1`);
|
|
344
|
+
parts.push(buildDockerStateSnippet(containerName));
|
|
175
345
|
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
176
|
-
parts.push(
|
|
346
|
+
parts.push(remove);
|
|
177
347
|
} else if (
|
|
178
348
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
|
|
179
349
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
|
|
180
350
|
) {
|
|
181
|
-
const successCondition = buildSuccessfulNonOomCondition();
|
|
182
351
|
parts.push(
|
|
183
|
-
`if ${
|
|
352
|
+
`if ${buildSuccessfulNonOomCondition()}; then ${remove}; else ${keep}; fi`
|
|
184
353
|
);
|
|
354
|
+
} else {
|
|
355
|
+
// KEEP: the container is never removed, so the log used to end with the
|
|
356
|
+
// raw command output and nothing else. The post-mortem is exactly the
|
|
357
|
+
// information an operator needs before deciding what to do with it.
|
|
358
|
+
parts.push(keep);
|
|
185
359
|
}
|
|
186
360
|
parts.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
|
|
187
361
|
} else {
|
|
188
362
|
parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
|
|
189
|
-
parts.push(
|
|
190
|
-
`__start_command_state=$(docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' ${quotedName} 2>/dev/null || printf '%s' '-1 false')`
|
|
191
|
-
);
|
|
192
|
-
parts.push('__start_command_exit=${__start_command_state%% *}');
|
|
193
|
-
parts.push('__start_command_oom=${__start_command_state##* }');
|
|
363
|
+
parts.push(buildDockerStateSnippet(containerName));
|
|
194
364
|
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
195
365
|
parts.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
|
|
196
366
|
} else if (
|
|
@@ -203,13 +373,39 @@ function buildDetachedDockerCompletionScript(containerName, policy, logPath) {
|
|
|
203
373
|
}
|
|
204
374
|
}
|
|
205
375
|
|
|
376
|
+
if (executionId) {
|
|
377
|
+
// Last, so the record is only marked terminal once the log is complete.
|
|
378
|
+
parts.push(buildDetachedFinalizeSnippet(executionId));
|
|
379
|
+
}
|
|
380
|
+
|
|
206
381
|
return parts.join('; ');
|
|
207
382
|
}
|
|
208
383
|
|
|
209
|
-
|
|
384
|
+
/**
|
|
385
|
+
* Start the detached completion watcher.
|
|
386
|
+
* @param {string} containerName - Docker container name
|
|
387
|
+
* @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
|
|
388
|
+
* @param {string|null} logPath - Log file to append to, or null
|
|
389
|
+
* @param {string|null} [executionId] - Execution UUID to finalize, or null
|
|
390
|
+
* @returns {void}
|
|
391
|
+
*/
|
|
392
|
+
function startDetachedDockerCompletionWatcher(
|
|
393
|
+
containerName,
|
|
394
|
+
policy,
|
|
395
|
+
logPath,
|
|
396
|
+
executionId = null
|
|
397
|
+
) {
|
|
210
398
|
const watcher = spawn(
|
|
211
399
|
'sh',
|
|
212
|
-
[
|
|
400
|
+
[
|
|
401
|
+
'-c',
|
|
402
|
+
buildDetachedDockerCompletionScript(
|
|
403
|
+
containerName,
|
|
404
|
+
policy,
|
|
405
|
+
logPath,
|
|
406
|
+
executionId
|
|
407
|
+
),
|
|
408
|
+
],
|
|
213
409
|
{
|
|
214
410
|
detached: true,
|
|
215
411
|
stdio: 'ignore',
|
|
@@ -244,7 +440,11 @@ function spawnAttachedDocker(dockerArgs, logPath) {
|
|
|
244
440
|
}
|
|
245
441
|
|
|
246
442
|
module.exports = {
|
|
443
|
+
buildAttachedDockerKeptMessage,
|
|
247
444
|
DOCKER_CONTAINER_CLEANUP_POLICY,
|
|
445
|
+
SELF_ABORT_EXIT_CODES,
|
|
446
|
+
OOM_FLAG_BLIND_NOTE,
|
|
447
|
+
buildDockerKeptReasonSnippet,
|
|
248
448
|
getDockerCommand,
|
|
249
449
|
getDockerSpawnOptions,
|
|
250
450
|
getDockerContainerCleanupPolicy,
|
|
@@ -252,8 +452,9 @@ module.exports = {
|
|
|
252
452
|
shouldCleanupDockerContainer,
|
|
253
453
|
getDockerContainerCleanupInstructions,
|
|
254
454
|
appendDockerContainerCleanupPolicyMessage,
|
|
255
|
-
|
|
455
|
+
readDockerContainerState,
|
|
256
456
|
readDockerContainerStatus,
|
|
457
|
+
recordAttachedDockerPostMortem,
|
|
257
458
|
removeDockerContainer,
|
|
258
459
|
buildDetachedDockerCompletionScript,
|
|
259
460
|
startDetachedDockerCompletionWatcher,
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container post-mortem facts (issues #170, #171).
|
|
3
|
+
*
|
|
4
|
+
* When a detached container dies the only thing `start` used to write into the
|
|
5
|
+
* log was `Reason: exitCode=137 oomKilled=false` — the two facts that, together,
|
|
6
|
+
* say the least. `137` is `128 + 9`, i.e. SIGKILL, and `oomKilled=false` rules
|
|
7
|
+
* out the cgroup OOM killer, so the container was killed from the outside
|
|
8
|
+
* (`docker kill`, a `docker stop` escalation, systemd, the CI runner). None of
|
|
9
|
+
* that was said out loud, and the three fields that would have shown *when* it
|
|
10
|
+
* happened — `State.StartedAt`, `State.FinishedAt`, `State.Error` — were never
|
|
11
|
+
* read at all, even though the watcher was already one `docker inspect` away
|
|
12
|
+
* from them.
|
|
13
|
+
*
|
|
14
|
+
* This module owns those facts in one place:
|
|
15
|
+
* - `describeExitCode()` decodes `128 + n` once, for the watcher (completion
|
|
16
|
+
* time, written into the log) and for `--status` (query time, in memory), so
|
|
17
|
+
* the two can never disagree (issue #171);
|
|
18
|
+
* - the `build*Snippet()` helpers generate the POSIX shell the detached watcher
|
|
19
|
+
* runs, including the signal table, so the shell and the runtime share a
|
|
20
|
+
* single source of truth;
|
|
21
|
+
* - `formatContainerPostMortem()` renders the same block for callers that
|
|
22
|
+
* already hold the facts in memory (the attached docker path).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
SIGNAL_NAMES,
|
|
27
|
+
UNKNOWN_EXIT_CODE,
|
|
28
|
+
describeExitCode,
|
|
29
|
+
} = require('./exit-reason');
|
|
30
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Docker's zero value for a timestamp that was never set. `State.FinishedAt`
|
|
34
|
+
* carries it for every container that has not finished, and `State.StartedAt`
|
|
35
|
+
* for every container that never started. It must never reach a record as an
|
|
36
|
+
* `endTime`: a year-1 timestamp is worse than an honest `null`.
|
|
37
|
+
*/
|
|
38
|
+
const DOCKER_ZERO_TIME = '0001-01-01T00:00:00Z';
|
|
39
|
+
|
|
40
|
+
/** Placeholder written instead of a fact that could not be observed. */
|
|
41
|
+
const UNKNOWN = UNKNOWN_EXIT_CODE;
|
|
42
|
+
|
|
43
|
+
/** Placeholder for `State.Error` when docker reported no error at all. */
|
|
44
|
+
const NO_ERROR = '(none)';
|
|
45
|
+
|
|
46
|
+
/** Header line of the post-mortem block appended to a kept container's log. */
|
|
47
|
+
const POST_MORTEM_HEADER = '=== Container post-mortem ===';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Normalize a timestamp reported by `docker inspect`.
|
|
51
|
+
* @param {string|null|undefined} value - Raw `State.StartedAt`/`State.FinishedAt`
|
|
52
|
+
* @returns {string|null} The timestamp, or null when docker reported no time
|
|
53
|
+
*/
|
|
54
|
+
function normalizeDockerTimestamp(value) {
|
|
55
|
+
const text = String(
|
|
56
|
+
value === null || value === undefined ? '' : value
|
|
57
|
+
).trim();
|
|
58
|
+
if (!text || text === DOCKER_ZERO_TIME || text === UNKNOWN) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (text === '<no value>' || !Number.isFinite(Date.parse(text))) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format the time a container was alive.
|
|
69
|
+
* @param {string|null} startedAt - `State.StartedAt`
|
|
70
|
+
* @param {string|null} finishedAt - `State.FinishedAt`
|
|
71
|
+
* @returns {string|null} Lifetime such as `5.798s`, or null when unknown
|
|
72
|
+
*/
|
|
73
|
+
function formatLifetime(startedAt, finishedAt) {
|
|
74
|
+
const start = normalizeDockerTimestamp(startedAt);
|
|
75
|
+
const end = normalizeDockerTimestamp(finishedAt);
|
|
76
|
+
if (!start || !end) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const ms = Date.parse(end) - Date.parse(start);
|
|
80
|
+
if (!Number.isFinite(ms) || ms < 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return `${Math.floor(ms / 1000)}.${String(ms % 1000).padStart(3, '0')}s`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render the post-mortem block for facts already held in memory.
|
|
88
|
+
* @param {object} facts - Container name and inspected `State` fields
|
|
89
|
+
* @returns {string} Block text, newline terminated
|
|
90
|
+
*/
|
|
91
|
+
function formatContainerPostMortem(facts = {}) {
|
|
92
|
+
const startedAt = normalizeDockerTimestamp(facts.startedAt);
|
|
93
|
+
const finishedAt = normalizeDockerTimestamp(facts.finishedAt);
|
|
94
|
+
const lifetime = formatLifetime(facts.startedAt, facts.finishedAt);
|
|
95
|
+
const error = String(facts.error || '').trim();
|
|
96
|
+
return [
|
|
97
|
+
POST_MORTEM_HEADER,
|
|
98
|
+
`Container: ${facts.containerName || UNKNOWN}`,
|
|
99
|
+
`Exit Code: ${describeExitCode(facts.exitCode).text}`,
|
|
100
|
+
`OOMKilled: ${facts.oomKilled === undefined || facts.oomKilled === null ? UNKNOWN : facts.oomKilled}`,
|
|
101
|
+
`StartedAt: ${startedAt || UNKNOWN}`,
|
|
102
|
+
`FinishedAt: ${finishedAt || UNKNOWN}`,
|
|
103
|
+
`Lifetime: ${lifetime || UNKNOWN}`,
|
|
104
|
+
`Error: ${error || NO_ERROR}`,
|
|
105
|
+
'',
|
|
106
|
+
].join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Render the single line written when a container is removed, so a successful
|
|
111
|
+
* run keeps its log terse while still carrying the same facts (issue #171).
|
|
112
|
+
* @param {object} facts - Container name and inspected `State` fields
|
|
113
|
+
* @returns {string} One line, newline terminated
|
|
114
|
+
*/
|
|
115
|
+
function formatContainerRemovalNote(facts = {}) {
|
|
116
|
+
const described = describeExitCode(facts.exitCode);
|
|
117
|
+
const signal = described.signal ? `, ${described.signal}` : '';
|
|
118
|
+
const lifetime = formatLifetime(facts.startedAt, facts.finishedAt) || UNKNOWN;
|
|
119
|
+
const oomKilled =
|
|
120
|
+
facts.oomKilled === undefined || facts.oomKilled === null
|
|
121
|
+
? UNKNOWN
|
|
122
|
+
: facts.oomKilled;
|
|
123
|
+
return (
|
|
124
|
+
`Container removed: ${facts.containerName || UNKNOWN} ` +
|
|
125
|
+
`(exit ${described.code === null ? UNKNOWN : described.code}${signal}, ` +
|
|
126
|
+
`lifetime ${lifetime}, oomKilled=${oomKilled})\n`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Shell variables the completion watcher fills in and the snippets consume. */
|
|
131
|
+
const SHELL_VARS = {
|
|
132
|
+
exit: '__start_command_exit',
|
|
133
|
+
oom: '__start_command_oom',
|
|
134
|
+
started: '__start_command_started',
|
|
135
|
+
finished: '__start_command_finished',
|
|
136
|
+
error: '__start_command_error',
|
|
137
|
+
errorText: '__start_command_error_text',
|
|
138
|
+
signal: '__start_command_signal',
|
|
139
|
+
exitText: '__start_command_exit_text',
|
|
140
|
+
lifetime: '__start_command_lifetime',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** `docker inspect -f` template collecting every post-mortem fact at once. */
|
|
144
|
+
const DOCKER_STATE_INSPECT_FORMAT =
|
|
145
|
+
'{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.StartedAt}} {{.State.FinishedAt}}';
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Shell fragment reading the container's terminal state.
|
|
149
|
+
*
|
|
150
|
+
* The four whitespace-free fields are split with `set --` rather than the
|
|
151
|
+
* `${var%% *}` / `${var##* }` pair the two-field version used: those only ever
|
|
152
|
+
* reach the first and the last field. `State.Error` is read separately because
|
|
153
|
+
* it is free-form text that can contain spaces.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} containerName - Container to inspect
|
|
156
|
+
* @returns {string} Shell command
|
|
157
|
+
*/
|
|
158
|
+
function buildDockerStateSnippet(containerName) {
|
|
159
|
+
const quotedName = shellQuote(containerName);
|
|
160
|
+
const v = SHELL_VARS;
|
|
161
|
+
return [
|
|
162
|
+
`__start_command_state=$(docker inspect -f '${DOCKER_STATE_INSPECT_FORMAT}' ${quotedName} 2>/dev/null || printf '%s' '-1 false ${UNKNOWN} ${UNKNOWN}')`,
|
|
163
|
+
'set -- $__start_command_state',
|
|
164
|
+
`${v.exit}=\${1:--1}`,
|
|
165
|
+
`${v.oom}=\${2:-false}`,
|
|
166
|
+
`${v.started}=\${3:-${UNKNOWN}}`,
|
|
167
|
+
`${v.finished}=\${4:-${UNKNOWN}}`,
|
|
168
|
+
`${v.error}=$(docker inspect -f '{{.State.Error}}' ${quotedName} 2>/dev/null)`,
|
|
169
|
+
`case "$${v.started}" in ${DOCKER_ZERO_TIME}|'') ${v.started}=${UNKNOWN};; esac`,
|
|
170
|
+
`case "$${v.finished}" in ${DOCKER_ZERO_TIME}|'') ${v.finished}=${UNKNOWN};; esac`,
|
|
171
|
+
buildSignalDecodeSnippet(),
|
|
172
|
+
buildLifetimeSnippet(),
|
|
173
|
+
].join('; ');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Shell fragment decoding `128 + n` into a signal name.
|
|
178
|
+
*
|
|
179
|
+
* The `case` arms are generated from the very table `describeExitCode()` uses,
|
|
180
|
+
* so the log the watcher writes and the `exitReason` `--status` computes can
|
|
181
|
+
* never name different signals for the same code (issue #171).
|
|
182
|
+
*
|
|
183
|
+
* @returns {string} Shell command
|
|
184
|
+
*/
|
|
185
|
+
function buildSignalDecodeSnippet() {
|
|
186
|
+
const v = SHELL_VARS;
|
|
187
|
+
const arms = Object.entries(SIGNAL_NAMES)
|
|
188
|
+
.map(([signal, name]) => `${128 + Number(signal)}) ${v.signal}=${name};;`)
|
|
189
|
+
.join(' ');
|
|
190
|
+
return [
|
|
191
|
+
`${v.signal}=''`,
|
|
192
|
+
`case "$${v.exit}" in ${arms} esac`,
|
|
193
|
+
`${v.exitText}="$${v.exit}"`,
|
|
194
|
+
`if [ -n "$${v.signal}" ]; then ${v.exitText}="$${v.exit} ($${v.signal} - 128+$((${v.exit} - 128)))"; fi`,
|
|
195
|
+
].join('; ');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Shell fragment computing how long the container was alive.
|
|
200
|
+
*
|
|
201
|
+
* Best effort by design: `date -d` is GNU, `date -j -f` is BSD, and a host that
|
|
202
|
+
* offers neither leaves the lifetime `unknown` rather than guessing.
|
|
203
|
+
*
|
|
204
|
+
* BSD `date` cannot parse the fractional seconds docker reports, so the
|
|
205
|
+
* fallback strips them before parsing and adds them back afterwards. Truncating
|
|
206
|
+
* both timestamps to whole seconds instead would *not* cancel out: a container
|
|
207
|
+
* alive from `.942` to `.740` of the next-but-five second measures 5.798s but
|
|
208
|
+
* truncates to 6.000s, and the error can reach a full second either way.
|
|
209
|
+
*
|
|
210
|
+
* @returns {string} Shell command
|
|
211
|
+
*/
|
|
212
|
+
function buildLifetimeSnippet() {
|
|
213
|
+
const v = SHELL_VARS;
|
|
214
|
+
return [
|
|
215
|
+
'__start_command_millis() { ' +
|
|
216
|
+
'case "$1" in *.*) __start_command_frac=${1#*.};; ' +
|
|
217
|
+
"*) printf '000'; return;; esac; " +
|
|
218
|
+
'__start_command_frac=${__start_command_frac%%[!0-9]*}; ' +
|
|
219
|
+
'printf \'%.3s\' "${__start_command_frac}000"; }',
|
|
220
|
+
'__start_command_epoch_ms() { ' +
|
|
221
|
+
'__start_command_ts=$(date -u -d "$1" +%s%3N 2>/dev/null); ' +
|
|
222
|
+
'case "$__start_command_ts" in \'\'|*[!0-9]*) ' +
|
|
223
|
+
'__start_command_ts=$(date -u -j -f \'%Y-%m-%dT%H:%M:%S\' "${1%.*}" +%s 2>/dev/null) && ' +
|
|
224
|
+
'__start_command_ts="${__start_command_ts}$(__start_command_millis "$1")";; esac; ' +
|
|
225
|
+
"case \"$__start_command_ts\" in ''|*[!0-9]*) __start_command_ts='';; esac; " +
|
|
226
|
+
'printf \'%s\' "$__start_command_ts"; }',
|
|
227
|
+
`${v.lifetime}=${UNKNOWN}`,
|
|
228
|
+
`__start_command_t0=$(__start_command_epoch_ms "$${v.started}")`,
|
|
229
|
+
`__start_command_t1=$(__start_command_epoch_ms "$${v.finished}")`,
|
|
230
|
+
'if [ -n "$__start_command_t0" ] && [ -n "$__start_command_t1" ] && ' +
|
|
231
|
+
'[ "$__start_command_t1" -ge "$__start_command_t0" ] 2>/dev/null; then ' +
|
|
232
|
+
'__start_command_ms=$((__start_command_t1 - __start_command_t0)); ' +
|
|
233
|
+
`${v.lifetime}="$((__start_command_ms / 1000)).$(printf '%03d' "$((__start_command_ms % 1000))")s"; fi`,
|
|
234
|
+
].join('; ');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Shell fragment appending the post-mortem block for a kept container.
|
|
239
|
+
* @param {string} containerName - Container that was kept
|
|
240
|
+
* @param {string} quotedLogPath - Already shell-quoted log path
|
|
241
|
+
* @returns {string} Shell command
|
|
242
|
+
*/
|
|
243
|
+
function buildDockerPostMortemSnippet(containerName, quotedLogPath) {
|
|
244
|
+
const v = SHELL_VARS;
|
|
245
|
+
return (
|
|
246
|
+
`${v.errorText}="$${v.error}"; ` +
|
|
247
|
+
`if [ -z "$${v.errorText}" ]; then ${v.errorText}='${NO_ERROR}'; fi; ` +
|
|
248
|
+
`printf '\\n${POST_MORTEM_HEADER}\\nContainer: %s\\nExit Code: %s\\n` +
|
|
249
|
+
`OOMKilled: %s\\nStartedAt: %s\\nFinishedAt: %s\\nLifetime: %s\\nError: %s\\n' ` +
|
|
250
|
+
`${shellQuote(containerName)} "$${v.exitText}" "$${v.oom}" ` +
|
|
251
|
+
`"$${v.started}" "$${v.finished}" "$${v.lifetime}" "$${v.errorText}" >> ${quotedLogPath}`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Shell fragment appending the one-line note for a removed container.
|
|
257
|
+
* @param {string} containerName - Container that was removed
|
|
258
|
+
* @param {string} quotedLogPath - Already shell-quoted log path
|
|
259
|
+
* @returns {string} Shell command
|
|
260
|
+
*/
|
|
261
|
+
function buildDockerRemovalNoteSnippet(containerName, quotedLogPath) {
|
|
262
|
+
const v = SHELL_VARS;
|
|
263
|
+
return (
|
|
264
|
+
`__start_command_signal_note=''; ` +
|
|
265
|
+
`if [ -n "$${v.signal}" ]; then __start_command_signal_note=", $${v.signal}"; fi; ` +
|
|
266
|
+
`printf '\\nContainer removed: %s (exit %s%s, lifetime %s, oomKilled=%s)\\n' ` +
|
|
267
|
+
`${shellQuote(containerName)} "$${v.exit}" "$__start_command_signal_note" ` +
|
|
268
|
+
`"$${v.lifetime}" "$${v.oom}" >> ${quotedLogPath}`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = {
|
|
273
|
+
DOCKER_STATE_INSPECT_FORMAT,
|
|
274
|
+
DOCKER_ZERO_TIME,
|
|
275
|
+
NO_ERROR,
|
|
276
|
+
POST_MORTEM_HEADER,
|
|
277
|
+
SHELL_VARS,
|
|
278
|
+
UNKNOWN,
|
|
279
|
+
buildDockerPostMortemSnippet,
|
|
280
|
+
buildDockerRemovalNoteSnippet,
|
|
281
|
+
buildDockerStateSnippet,
|
|
282
|
+
buildLifetimeSnippet,
|
|
283
|
+
buildSignalDecodeSnippet,
|
|
284
|
+
describeExitCode,
|
|
285
|
+
formatContainerPostMortem,
|
|
286
|
+
formatContainerRemovalNote,
|
|
287
|
+
formatLifetime,
|
|
288
|
+
normalizeDockerTimestamp,
|
|
289
|
+
};
|