start-command 0.33.0 → 0.34.1
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 +1 -1
- package/src/bin/cli.js +11 -2
- package/src/lib/command-stream.js +4 -2
- package/src/lib/detached-finalize.js +305 -0
- package/src/lib/docker-cleanup.js +185 -38
- package/src/lib/docker-post-mortem.js +382 -0
- package/src/lib/execution-resume-all.js +2 -1
- package/src/lib/execution-resume.js +2 -1
- package/src/lib/execution-store.js +46 -2
- package/src/lib/exit-reason.js +54 -0
- package/src/lib/isolation-log-utils.js +16 -5
- package/src/lib/isolation.js +17 -7
- package/src/lib/spawn-helpers.js +4 -2
- package/src/lib/status-formatter.js +115 -24
- package/test/execution-store.js +5 -2
- package/test/regression-170.js +324 -0
- package/test/regression-171.js +562 -0
- package/test/regression-174.js +397 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.34.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
## 0.34.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 22c1158: Persist the terminal state of a detached Docker execution and record the container post-mortem. The completion watcher now writes `status`, `exitCode`, `oomKilled` and a real `endTime` back into the store instead of leaving the record `executing` forever, so `--status` stops calling the clock at query time and reporting a different "finish" on every call. `endTime` carries its provenance in `endTimeSource` (`docker-finished-at`, `log-footer`, `observed-at`); a stale record keeps `endTime` empty and records `staleDetectedAt` instead of a fabricated finish time. The single `docker inspect` now also collects `StartedAt`, `FinishedAt` and `State.Error`, and both the detached and the attached path write a post-mortem block for a kept container and a one-line note for a removed one. Signal decoding (`128+n`) is shared by the log and `--status`, so `137` reads as `137 (SIGKILL - 128+9)` everywhere.
|
|
14
|
+
|
|
3
15
|
## 0.33.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/package.json
CHANGED
package/src/bin/cli.js
CHANGED
|
@@ -33,10 +33,12 @@ const {
|
|
|
33
33
|
} = require('../lib/user-manager');
|
|
34
34
|
const { handleFailure } = require('../lib/failure-handler');
|
|
35
35
|
const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
|
|
36
|
+
const { reconcileFinalizedRecord } = require('../lib/detached-finalize');
|
|
36
37
|
const { dispatchQueryCommand, hasQueryMode } = require('../lib/query-commands');
|
|
37
38
|
const { printVersion } = require('../lib/version');
|
|
38
39
|
const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
|
|
39
40
|
const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
|
|
41
|
+
const { resolveChildExitCode } = require('../lib/exit-reason');
|
|
40
42
|
const { printUsage } = require('../lib/usage');
|
|
41
43
|
const { buildDisplayCommand, getCommandName } = require('../lib/shell-utils');
|
|
42
44
|
|
|
@@ -469,6 +471,9 @@ async function runWithIsolation(
|
|
|
469
471
|
...buildDockerRuntimeMetadata(options),
|
|
470
472
|
shell: options.shell,
|
|
471
473
|
logPath: logFilePath,
|
|
474
|
+
// Lets the detached completion watcher write the terminal state back
|
|
475
|
+
// into this record once the container is gone (issue #170.1).
|
|
476
|
+
executionId: executionRecord ? executionRecord.uuid : null,
|
|
472
477
|
});
|
|
473
478
|
} else if (createdUser) {
|
|
474
479
|
// Run directly as the created user (no isolation environment)
|
|
@@ -504,7 +509,11 @@ async function runWithIsolation(
|
|
|
504
509
|
executionRecord.complete(exitCode);
|
|
505
510
|
}
|
|
506
511
|
try {
|
|
507
|
-
store.save(
|
|
512
|
+
store.save(
|
|
513
|
+
mode === 'detached'
|
|
514
|
+
? reconcileFinalizedRecord(store, executionRecord)
|
|
515
|
+
: executionRecord
|
|
516
|
+
);
|
|
508
517
|
} catch (err) {
|
|
509
518
|
if (config.verbose) {
|
|
510
519
|
console.error(
|
|
@@ -812,7 +821,7 @@ async function runDirectWithCommandStream(
|
|
|
812
821
|
// Use raw() to pass the command without auto-escaping
|
|
813
822
|
// This is important for complex commands with pipes, redirects, etc.
|
|
814
823
|
const result = await $cmd`${raw(cmd)}`;
|
|
815
|
-
exitCode = result.code
|
|
824
|
+
exitCode = resolveChildExitCode(result.code);
|
|
816
825
|
|
|
817
826
|
// Update PID if available from result
|
|
818
827
|
if (executionRecord && result.pid) {
|
|
@@ -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) {
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detached completion finalizer (issue #170)
|
|
3
|
+
*
|
|
4
|
+
* A detached Docker session outlives the CLI process that started it: the
|
|
5
|
+
* foreground `start` invocation returns as soon as the container is up, so
|
|
6
|
+
* nobody is left to write the terminal state back into the execution store.
|
|
7
|
+
* The record therefore stayed `executing` forever, with `exitCode: null` and
|
|
8
|
+
* `endTime: null`, and every `--status` query had to re-derive the outcome from
|
|
9
|
+
* scratch — fabricating `endTime` with `new Date()` in the process.
|
|
10
|
+
*
|
|
11
|
+
* The detached completion watcher (see `docker-cleanup.js`) now re-invokes this
|
|
12
|
+
* module once the container is gone, handing over the facts it already read out
|
|
13
|
+
* of a single `docker inspect`. This module is deliberately tiny and
|
|
14
|
+
* dependency-light: it is executed as a short-lived child process on a host
|
|
15
|
+
* whose CLI invocation is long gone, so it must never throw and never block.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
SHELL_VARS,
|
|
22
|
+
describeExitCode,
|
|
23
|
+
normalizeDockerTimestamp,
|
|
24
|
+
} = require('./docker-post-mortem');
|
|
25
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
26
|
+
|
|
27
|
+
/** Provenance markers for `endTime` (issue #170.2). */
|
|
28
|
+
const END_TIME_SOURCE = {
|
|
29
|
+
/** `docker inspect .State.FinishedAt` — the container's own clock. */
|
|
30
|
+
DOCKER_FINISHED_AT: 'docker-finished-at',
|
|
31
|
+
/** The anchored `Finished:` line `start` itself wrote into the log. */
|
|
32
|
+
LOG_FOOTER: 'log-footer',
|
|
33
|
+
/** No real finish time exists: this is when the end was *observed*. */
|
|
34
|
+
OBSERVED_AT: 'observed-at',
|
|
35
|
+
};
|
|
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
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Persist the terminal state of a detached execution.
|
|
46
|
+
*
|
|
47
|
+
* Purely additive with respect to the observation-vs-verdict discipline of
|
|
48
|
+
* issues #148/#151/#162: `oomKilled` and `exitReason` are recorded as facts
|
|
49
|
+
* next to the exit code, never as a substitute for it.
|
|
50
|
+
*
|
|
51
|
+
* @param {Object} options - Finalization input
|
|
52
|
+
* @param {Object} options.store - An `ExecutionStore`
|
|
53
|
+
* @param {string} options.executionId - UUID (or session name) of the record
|
|
54
|
+
* @param {number|string|null} [options.exitCode] - `.State.ExitCode`
|
|
55
|
+
* @param {boolean|string|null} [options.oomKilled] - `.State.OOMKilled`
|
|
56
|
+
* @param {string|null} [options.startedAt] - `.State.StartedAt`
|
|
57
|
+
* @param {string|null} [options.finishedAt] - `.State.FinishedAt`
|
|
58
|
+
* @param {string|null} [options.containerError] - `.State.Error`
|
|
59
|
+
* @param {boolean|string|null} [options.running] - `.State.Running`
|
|
60
|
+
* @returns {{updated: boolean, reason?: string, record?: Object}} Outcome
|
|
61
|
+
*/
|
|
62
|
+
function finalizeDetachedExecution(options = {}) {
|
|
63
|
+
const { store, executionId } = options;
|
|
64
|
+
if (!store || !executionId) {
|
|
65
|
+
return { updated: false, reason: 'missing-arguments' };
|
|
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
|
+
}
|
|
72
|
+
|
|
73
|
+
let record;
|
|
74
|
+
try {
|
|
75
|
+
record = store.get(executionId);
|
|
76
|
+
} catch {
|
|
77
|
+
return { updated: false, reason: 'store-unreadable' };
|
|
78
|
+
}
|
|
79
|
+
if (!record) {
|
|
80
|
+
return { updated: false, reason: 'record-not-found' };
|
|
81
|
+
}
|
|
82
|
+
if (record.status === 'executed' && record.endTime) {
|
|
83
|
+
// Already finalized (e.g. a `--status` query got there first, or the
|
|
84
|
+
// watcher ran twice after a resume). Nothing to correct.
|
|
85
|
+
return { updated: false, reason: 'already-final', record };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const finishedAt = normalizeDockerTimestamp(options.finishedAt);
|
|
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;
|
|
93
|
+
record.status = 'executed';
|
|
94
|
+
if (exitCode !== null) {
|
|
95
|
+
record.exitCode = exitCode;
|
|
96
|
+
} else if (
|
|
97
|
+
record.exitCode === null ||
|
|
98
|
+
record.exitCode === undefined ||
|
|
99
|
+
!finishedAt
|
|
100
|
+
) {
|
|
101
|
+
record.exitCode = -1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (finishedAt) {
|
|
105
|
+
record.endTime = new Date(finishedAt).toISOString();
|
|
106
|
+
record.endTimeSource = END_TIME_SOURCE.DOCKER_FINISHED_AT;
|
|
107
|
+
} else {
|
|
108
|
+
// Docker has no finish time for this container (it never started, or it
|
|
109
|
+
// was removed before we could look). Record *when we noticed*, and say so,
|
|
110
|
+
// rather than passing observation time off as a finish time (issue #170.2).
|
|
111
|
+
record.endTime = new Date().toISOString();
|
|
112
|
+
record.endTimeSource = END_TIME_SOURCE.OBSERVED_AT;
|
|
113
|
+
record.observedAt = record.endTime;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const startedAt = normalizeDockerTimestamp(options.startedAt);
|
|
117
|
+
if (startedAt) {
|
|
118
|
+
record.containerStartedAt = startedAt;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const oomKilled = normalizeBoolean(options.oomKilled);
|
|
122
|
+
if (oomKilled !== null) {
|
|
123
|
+
record.oomKilled = oomKilled;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const exitReason =
|
|
127
|
+
resolveReason(record) || (finishedAt ? null : WATCHER_LOST_CONTAINER);
|
|
128
|
+
if (exitReason) {
|
|
129
|
+
record.exitReason = exitReason;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const containerError = normalizeContainerError(options.containerError);
|
|
133
|
+
if (containerError) {
|
|
134
|
+
record.options = { ...(record.options || {}), containerError };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
store.save(record);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return { updated: false, reason: `save-failed: ${err.message}`, record };
|
|
141
|
+
}
|
|
142
|
+
return { updated: true, record };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Derive the `exitReason` hint from the same evidence `--status` would use.
|
|
147
|
+
* Kept in a helper so a failure to read the log never aborts finalization.
|
|
148
|
+
* @param {Object} record - Execution record being finalized
|
|
149
|
+
* @returns {string|null} Reason hint, or null
|
|
150
|
+
*/
|
|
151
|
+
function resolveReason(record) {
|
|
152
|
+
try {
|
|
153
|
+
const { resolveExitReason } = require('./exit-reason');
|
|
154
|
+
const {
|
|
155
|
+
FATAL_MARKER_TAIL_BYTES,
|
|
156
|
+
readLogTail,
|
|
157
|
+
} = require('./isolation-log-utils');
|
|
158
|
+
const logTail = record.logPath
|
|
159
|
+
? readLogTail(record.logPath, FATAL_MARKER_TAIL_BYTES)
|
|
160
|
+
: null;
|
|
161
|
+
return resolveExitReason({
|
|
162
|
+
exitCode: record.exitCode,
|
|
163
|
+
logTail,
|
|
164
|
+
oomKilled: record.oomKilled,
|
|
165
|
+
});
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {boolean|string|null|undefined} value - Raw shell-provided flag
|
|
173
|
+
* @returns {boolean|null} Parsed flag, or null when it carries no information
|
|
174
|
+
*/
|
|
175
|
+
function normalizeBoolean(value) {
|
|
176
|
+
if (typeof value === 'boolean') {
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
if (value === 'true') {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
if (value === 'false') {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string|null|undefined} value - Raw `.State.Error`
|
|
190
|
+
* @returns {string|null} Error text, or null when docker reported none
|
|
191
|
+
*/
|
|
192
|
+
function normalizeContainerError(value) {
|
|
193
|
+
if (typeof value !== 'string') {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const trimmed = value.trim();
|
|
197
|
+
if (trimmed === '' || trimmed === '<no value>' || trimmed === '(none)') {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
return trimmed;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Reconcile an in-memory record with one the detached watcher already
|
|
205
|
+
* finalized.
|
|
206
|
+
*
|
|
207
|
+
* A container that exits almost immediately can be finalized by the watcher
|
|
208
|
+
* before the foreground `start` invocation writes its own last update. Saving
|
|
209
|
+
* the stale in-memory copy would resurrect `status: executing` and throw the
|
|
210
|
+
* terminal facts away, so the stored record wins and only the fields the
|
|
211
|
+
* foreground process learned (its `options`, e.g. `containerId`) are merged in.
|
|
212
|
+
*
|
|
213
|
+
* @param {Object} store - An `ExecutionStore`
|
|
214
|
+
* @param {Object} record - The in-memory record about to be saved
|
|
215
|
+
* @returns {Object} The record to save
|
|
216
|
+
*/
|
|
217
|
+
function reconcileFinalizedRecord(store, record) {
|
|
218
|
+
try {
|
|
219
|
+
const stored = store.get(record.uuid);
|
|
220
|
+
if (!stored || stored.status !== 'executed' || !stored.endTime) {
|
|
221
|
+
return record;
|
|
222
|
+
}
|
|
223
|
+
stored.options = { ...(stored.options || {}), ...(record.options || {}) };
|
|
224
|
+
return stored;
|
|
225
|
+
} catch {
|
|
226
|
+
return record;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Shell fragment that hands the inspected docker facts to this module.
|
|
232
|
+
*
|
|
233
|
+
* Runs with the same runtime that started the session, inherits
|
|
234
|
+
* `START_APP_FOLDER`/`START_DISABLE_TRACKING` from the detached watcher, and is
|
|
235
|
+
* suffixed with `|| true` so a finalization failure can never abort the
|
|
236
|
+
* watcher's remaining cleanup work.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} executionId - UUID of the record to finalize
|
|
239
|
+
* @returns {string} POSIX shell fragment
|
|
240
|
+
*/
|
|
241
|
+
function buildDetachedFinalizeSnippet(executionId) {
|
|
242
|
+
const v = SHELL_VARS;
|
|
243
|
+
return (
|
|
244
|
+
`${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
|
|
245
|
+
`${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
|
|
246
|
+
`"$${v.started}" "$${v.finished}" "$${v.error}" "$${v.running}" ` +
|
|
247
|
+
`>/dev/null 2>&1 || true`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Entry point used by the detached watcher:
|
|
253
|
+
* <runtime> detached-finalize.js <uuid> <exit> <oom> <started> <finished> <error> <running>
|
|
254
|
+
* Always exits 0 — a bookkeeping failure must never turn into a visible error
|
|
255
|
+
* in a log the user is reading for the command's own output.
|
|
256
|
+
* @param {string[]} argv - Positional arguments
|
|
257
|
+
* @returns {void}
|
|
258
|
+
*/
|
|
259
|
+
function main(argv) {
|
|
260
|
+
const [
|
|
261
|
+
executionId,
|
|
262
|
+
exitCode,
|
|
263
|
+
oomKilled,
|
|
264
|
+
startedAt,
|
|
265
|
+
finishedAt,
|
|
266
|
+
containerError,
|
|
267
|
+
running,
|
|
268
|
+
] = argv;
|
|
269
|
+
if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const { ExecutionStore } = require('./execution-store');
|
|
274
|
+
const store = new ExecutionStore(
|
|
275
|
+
process.env.START_APP_FOLDER
|
|
276
|
+
? { appFolder: path.resolve(process.env.START_APP_FOLDER) }
|
|
277
|
+
: {}
|
|
278
|
+
);
|
|
279
|
+
finalizeDetachedExecution({
|
|
280
|
+
store,
|
|
281
|
+
executionId,
|
|
282
|
+
exitCode,
|
|
283
|
+
oomKilled,
|
|
284
|
+
startedAt,
|
|
285
|
+
finishedAt,
|
|
286
|
+
containerError,
|
|
287
|
+
running,
|
|
288
|
+
});
|
|
289
|
+
} catch {
|
|
290
|
+
// Deliberately silent: see the exit-0 contract above.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (require.main === module) {
|
|
295
|
+
main(process.argv.slice(2));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = {
|
|
299
|
+
END_TIME_SOURCE,
|
|
300
|
+
WATCHER_LOST_CONTAINER,
|
|
301
|
+
buildDetachedFinalizeSnippet,
|
|
302
|
+
finalizeDetachedExecution,
|
|
303
|
+
main,
|
|
304
|
+
reconcileFinalizedRecord,
|
|
305
|
+
};
|
|
@@ -7,6 +7,19 @@ const {
|
|
|
7
7
|
shellQuote,
|
|
8
8
|
} = require('./isolation-log-utils');
|
|
9
9
|
const { resolveMemoryExhaustion } = require('./exit-reason');
|
|
10
|
+
const {
|
|
11
|
+
buildDockerPostMortemSnippet,
|
|
12
|
+
buildDockerRemovalNoteSnippet,
|
|
13
|
+
buildDockerStateSnippet,
|
|
14
|
+
buildDockerStillRunningNoteSnippet,
|
|
15
|
+
buildDockerWaitForExitSnippet,
|
|
16
|
+
DOCKER_STATE_INSPECT_FORMAT,
|
|
17
|
+
formatContainerPostMortem,
|
|
18
|
+
formatContainerRemovalNote,
|
|
19
|
+
normalizeDockerTimestamp,
|
|
20
|
+
SHELL_VARS,
|
|
21
|
+
} = require('./docker-post-mortem');
|
|
22
|
+
const { buildDetachedFinalizeSnippet } = require('./detached-finalize');
|
|
10
23
|
|
|
11
24
|
const DOCKER_CONTAINER_CLEANUP_POLICY = {
|
|
12
25
|
DEFAULT: 'default',
|
|
@@ -91,27 +104,85 @@ function appendDockerContainerCleanupPolicyMessage(
|
|
|
91
104
|
return `${message}\nContainer will be removed after command completes.`;
|
|
92
105
|
}
|
|
93
106
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Read a finished container's post-mortem facts in a single `docker inspect`.
|
|
109
|
+
*
|
|
110
|
+
* The attached path only ever inspected `State.OOMKilled`, which left its
|
|
111
|
+
* "Container kept for investigation" message unable to say *why* the container
|
|
112
|
+
* died — the same gap the detached watcher had (issue #171.1). Reading the
|
|
113
|
+
* facts once, before any `docker rm`, also makes them available to the removal
|
|
114
|
+
* path, where the container is about to stop existing (issue #171.3).
|
|
115
|
+
*
|
|
116
|
+
* @param {string} containerName - Container to inspect
|
|
117
|
+
* @returns {object|null} Facts accepted by the `docker-post-mortem` formatters,
|
|
118
|
+
* or null when the container could not be inspected at all
|
|
119
|
+
*/
|
|
120
|
+
function readDockerContainerState(containerName) {
|
|
121
|
+
const inspect = (format) => {
|
|
122
|
+
const result = spawnSync(
|
|
123
|
+
getDockerCommand(),
|
|
124
|
+
['inspect', '-f', format, containerName],
|
|
125
|
+
getDockerSpawnOptions({
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
env: process.env,
|
|
128
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
if (result.error || result.status !== 0) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return String(result.stdout || '').trim();
|
|
135
|
+
};
|
|
136
|
+
const state = inspect(DOCKER_STATE_INSPECT_FORMAT);
|
|
137
|
+
if (state === null) {
|
|
105
138
|
return null;
|
|
106
139
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
140
|
+
const [exitCode, oomKilled, startedAt, finishedAt] = state.split(/\s+/);
|
|
141
|
+
// `State.Error` is free-form text that may contain spaces, so it cannot ride
|
|
142
|
+
// along in the whitespace-separated template above.
|
|
143
|
+
const error = inspect('{{.State.Error}}');
|
|
144
|
+
const parsedExit = Number.parseInt(exitCode, 10);
|
|
145
|
+
return {
|
|
146
|
+
containerName,
|
|
147
|
+
exitCode: Number.isFinite(parsedExit) ? parsedExit : null,
|
|
148
|
+
oomKilled:
|
|
149
|
+
oomKilled === 'true' ? true : oomKilled === 'false' ? false : null,
|
|
150
|
+
startedAt: normalizeDockerTimestamp(startedAt),
|
|
151
|
+
finishedAt: normalizeDockerTimestamp(finishedAt),
|
|
152
|
+
error: error || null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Write the post-mortem for an attached run into its log and return the same
|
|
158
|
+
* text for the console message.
|
|
159
|
+
*
|
|
160
|
+
* Kept and removed containers both get their facts recorded — a kept container
|
|
161
|
+
* gets the full block, a removed one the single line — so the log of an
|
|
162
|
+
* attached run carries exactly what the detached watcher writes (issues #171.2
|
|
163
|
+
* and #171.3).
|
|
164
|
+
*
|
|
165
|
+
* @param {object} params - Container name, inspected state, log path, and
|
|
166
|
+
* whether the container was removed
|
|
167
|
+
* @returns {string} Message lines to append, or '' when nothing is known
|
|
168
|
+
*/
|
|
169
|
+
function recordAttachedDockerPostMortem({
|
|
170
|
+
containerName,
|
|
171
|
+
state,
|
|
172
|
+
logPath = null,
|
|
173
|
+
removed = false,
|
|
174
|
+
}) {
|
|
175
|
+
if (!state) {
|
|
176
|
+
return '';
|
|
110
177
|
}
|
|
111
|
-
|
|
112
|
-
|
|
178
|
+
const facts = { ...state, containerName };
|
|
179
|
+
const text = removed
|
|
180
|
+
? formatContainerRemovalNote(facts)
|
|
181
|
+
: formatContainerPostMortem(facts);
|
|
182
|
+
if (logPath) {
|
|
183
|
+
appendLogFile(logPath, `\n${text}`);
|
|
113
184
|
}
|
|
114
|
-
return
|
|
185
|
+
return `\n${text.trimEnd()}`;
|
|
115
186
|
}
|
|
116
187
|
|
|
117
188
|
function readDockerContainerStatus(containerName) {
|
|
@@ -231,56 +302,131 @@ function buildSuccessfulNonOomCondition() {
|
|
|
231
302
|
);
|
|
232
303
|
}
|
|
233
304
|
|
|
234
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Build the POSIX shell run by the detached completion watcher.
|
|
307
|
+
*
|
|
308
|
+
* The watcher is the only observer left once `start --detached` returns, so it
|
|
309
|
+
* carries three responsibilities that used to be missing or deferred:
|
|
310
|
+
* 1. it reads the container's post-mortem facts out of a single
|
|
311
|
+
* `docker inspect` (issue #171.1);
|
|
312
|
+
* 2. it writes those facts into the log on every path — kept *and* removed —
|
|
313
|
+
* so an operator reading the log never has to reconstruct them from a
|
|
314
|
+
* container that no longer exists (issues #171.2, #171.3);
|
|
315
|
+
* 3. it hands the same facts to the finalizer so the execution record stops
|
|
316
|
+
* being `executing` forever (issue #170.1).
|
|
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
|
+
* @param {string} containerName - Docker container name
|
|
324
|
+
* @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
|
|
325
|
+
* @param {string|null} logPath - Log file to append to, or null
|
|
326
|
+
* @param {string|null} [executionId] - Execution UUID to finalize, or null
|
|
327
|
+
* @returns {string} The shell script
|
|
328
|
+
*/
|
|
329
|
+
function buildDetachedDockerCompletionScript(
|
|
330
|
+
containerName,
|
|
331
|
+
policy,
|
|
332
|
+
logPath,
|
|
333
|
+
executionId = null
|
|
334
|
+
) {
|
|
235
335
|
const quotedName = shellQuote(containerName);
|
|
236
336
|
const parts = [];
|
|
337
|
+
// Everything that assumes the container has exited: cleanup, footer and
|
|
338
|
+
// finalization. Guarded as a whole by `.State.Running` below.
|
|
339
|
+
const exited = [];
|
|
237
340
|
|
|
238
341
|
if (logPath) {
|
|
239
342
|
const quotedLogPath = shellQuote(logPath);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
343
|
+
const removalNote = buildDockerRemovalNoteSnippet(
|
|
344
|
+
containerName,
|
|
345
|
+
quotedLogPath
|
|
346
|
+
);
|
|
347
|
+
const postMortem = buildDockerPostMortemSnippet(
|
|
348
|
+
containerName,
|
|
349
|
+
quotedLogPath
|
|
243
350
|
);
|
|
244
|
-
|
|
245
|
-
|
|
351
|
+
const remove = `docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; ${removalNote}`;
|
|
352
|
+
const keep = `${postMortem}; ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}`;
|
|
353
|
+
|
|
354
|
+
parts.push(`docker logs -f ${quotedName} >> ${quotedLogPath} 2>&1`);
|
|
355
|
+
parts.push(buildDockerWaitForExitSnippet(containerName, quotedLogPath));
|
|
356
|
+
parts.push(buildDockerStateSnippet(containerName));
|
|
246
357
|
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
247
|
-
|
|
358
|
+
exited.push(remove);
|
|
248
359
|
} else if (
|
|
249
360
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
|
|
250
361
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
|
|
251
362
|
) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
`if ${successCondition}; then docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; else ${buildDockerKeptLogSnippet(containerName, quotedLogPath)}; fi`
|
|
363
|
+
exited.push(
|
|
364
|
+
`if ${buildSuccessfulNonOomCondition()}; then ${remove}; else ${keep}; fi`
|
|
255
365
|
);
|
|
366
|
+
} else {
|
|
367
|
+
// KEEP: the container is never removed, so the log used to end with the
|
|
368
|
+
// raw command output and nothing else. The post-mortem is exactly the
|
|
369
|
+
// information an operator needs before deciding what to do with it.
|
|
370
|
+
exited.push(keep);
|
|
256
371
|
}
|
|
257
|
-
|
|
372
|
+
exited.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
|
|
258
373
|
} else {
|
|
259
374
|
parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
|
|
260
|
-
parts.push(
|
|
261
|
-
|
|
262
|
-
);
|
|
263
|
-
parts.push('__start_command_exit=${__start_command_state%% *}');
|
|
264
|
-
parts.push('__start_command_oom=${__start_command_state##* }');
|
|
375
|
+
parts.push(buildDockerWaitForExitSnippet(containerName));
|
|
376
|
+
parts.push(buildDockerStateSnippet(containerName));
|
|
265
377
|
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
266
|
-
|
|
378
|
+
exited.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
|
|
267
379
|
} else if (
|
|
268
380
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT ||
|
|
269
381
|
policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
|
|
270
382
|
) {
|
|
271
|
-
|
|
383
|
+
exited.push(
|
|
272
384
|
`if ${buildSuccessfulNonOomCondition()}; then docker rm -f ${quotedName} >/dev/null 2>&1 || true; fi`
|
|
273
385
|
);
|
|
274
386
|
}
|
|
275
387
|
}
|
|
276
388
|
|
|
389
|
+
if (executionId) {
|
|
390
|
+
// Last, so the record is only marked terminal once the log is complete.
|
|
391
|
+
exited.push(buildDetachedFinalizeSnippet(executionId));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const stillRunning = logPath
|
|
395
|
+
? buildDockerStillRunningNoteSnippet(containerName, shellQuote(logPath))
|
|
396
|
+
: ':';
|
|
397
|
+
parts.push(
|
|
398
|
+
`if [ "$${SHELL_VARS.running}" = true ]; then ${stillRunning}; ` +
|
|
399
|
+
`else ${exited.length ? exited.join('; ') : ':'}; fi`
|
|
400
|
+
);
|
|
401
|
+
|
|
277
402
|
return parts.join('; ');
|
|
278
403
|
}
|
|
279
404
|
|
|
280
|
-
|
|
405
|
+
/**
|
|
406
|
+
* Start the detached completion watcher.
|
|
407
|
+
* @param {string} containerName - Docker container name
|
|
408
|
+
* @param {string} policy - One of DOCKER_CONTAINER_CLEANUP_POLICY
|
|
409
|
+
* @param {string|null} logPath - Log file to append to, or null
|
|
410
|
+
* @param {string|null} [executionId] - Execution UUID to finalize, or null
|
|
411
|
+
* @returns {void}
|
|
412
|
+
*/
|
|
413
|
+
function startDetachedDockerCompletionWatcher(
|
|
414
|
+
containerName,
|
|
415
|
+
policy,
|
|
416
|
+
logPath,
|
|
417
|
+
executionId = null
|
|
418
|
+
) {
|
|
281
419
|
const watcher = spawn(
|
|
282
420
|
'sh',
|
|
283
|
-
[
|
|
421
|
+
[
|
|
422
|
+
'-c',
|
|
423
|
+
buildDetachedDockerCompletionScript(
|
|
424
|
+
containerName,
|
|
425
|
+
policy,
|
|
426
|
+
logPath,
|
|
427
|
+
executionId
|
|
428
|
+
),
|
|
429
|
+
],
|
|
284
430
|
{
|
|
285
431
|
detached: true,
|
|
286
432
|
stdio: 'ignore',
|
|
@@ -327,8 +473,9 @@ module.exports = {
|
|
|
327
473
|
shouldCleanupDockerContainer,
|
|
328
474
|
getDockerContainerCleanupInstructions,
|
|
329
475
|
appendDockerContainerCleanupPolicyMessage,
|
|
330
|
-
|
|
476
|
+
readDockerContainerState,
|
|
331
477
|
readDockerContainerStatus,
|
|
478
|
+
recordAttachedDockerPostMortem,
|
|
332
479
|
removeDockerContainer,
|
|
333
480
|
buildDetachedDockerCompletionScript,
|
|
334
481
|
startDetachedDockerCompletionWatcher,
|