start-command 0.33.0 → 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 +6 -0
- package/package.json +1 -1
- package/src/bin/cli.js +9 -1
- package/src/lib/detached-finalize.js +281 -0
- package/src/lib/docker-cleanup.js +160 -34
- package/src/lib/docker-post-mortem.js +289 -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 +30 -0
- package/src/lib/isolation-log-utils.js +8 -1
- package/src/lib/isolation.js +17 -7
- package/src/lib/status-formatter.js +115 -24
- package/test/execution-store.js +5 -2
- package/test/regression-170.js +321 -0
- package/test/regression-171.js +562 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #171: the "Container kept for investigation" log
|
|
3
|
+
* omits the post-mortem facts docker already has.
|
|
4
|
+
*
|
|
5
|
+
* 171.1 the watcher's single `docker inspect` must also collect
|
|
6
|
+
* `StartedAt`, `FinishedAt` and `Error`;
|
|
7
|
+
* 171.2 a post-mortem block must be appended whenever the container is kept;
|
|
8
|
+
* 171.3 the removal path must state the same facts in one line;
|
|
9
|
+
* 171.4 signal decoding (`128+n` -> name) lives in one shared helper used by
|
|
10
|
+
* both the watcher (completion time) and the status formatter.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { describe, it, expect, afterEach } = require('bun:test');
|
|
14
|
+
const { spawnSync } = require('child_process');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
buildDetachedDockerCompletionScript,
|
|
21
|
+
readDockerContainerState,
|
|
22
|
+
recordAttachedDockerPostMortem,
|
|
23
|
+
} = require('../src/lib/docker-cleanup');
|
|
24
|
+
const {
|
|
25
|
+
DOCKER_STATE_INSPECT_FORMAT,
|
|
26
|
+
buildDockerPostMortemSnippet,
|
|
27
|
+
buildDockerRemovalNoteSnippet,
|
|
28
|
+
buildDockerStateSnippet,
|
|
29
|
+
describeExitCode,
|
|
30
|
+
formatContainerPostMortem,
|
|
31
|
+
formatContainerRemovalNote,
|
|
32
|
+
formatLifetime,
|
|
33
|
+
normalizeDockerTimestamp,
|
|
34
|
+
} = require('../src/lib/docker-post-mortem');
|
|
35
|
+
const {
|
|
36
|
+
describeExitCode: reExported,
|
|
37
|
+
} = require('../src/lib/isolation-log-utils');
|
|
38
|
+
|
|
39
|
+
const tempDirs = [];
|
|
40
|
+
|
|
41
|
+
function makeTempDir(prefix) {
|
|
42
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
43
|
+
tempDirs.push(dir);
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
while (tempDirs.length > 0) {
|
|
49
|
+
const dir = tempDirs.pop();
|
|
50
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('issue #171.4: one shared exit-code describer', () => {
|
|
55
|
+
it('decodes 128+n into the signal name', () => {
|
|
56
|
+
expect(describeExitCode(137)).toEqual({
|
|
57
|
+
code: 137,
|
|
58
|
+
signal: 'SIGKILL',
|
|
59
|
+
text: '137 (SIGKILL - 128+9)',
|
|
60
|
+
});
|
|
61
|
+
expect(describeExitCode(143).signal).toBe('SIGTERM');
|
|
62
|
+
expect(describeExitCode(139).signal).toBe('SIGSEGV');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('leaves ordinary exit codes alone', () => {
|
|
66
|
+
expect(describeExitCode(0)).toEqual({ code: 0, signal: null, text: '0' });
|
|
67
|
+
expect(describeExitCode(1).text).toBe('1');
|
|
68
|
+
expect(describeExitCode(-1).text).toBe('-1');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reports unknown for a non numeric code', () => {
|
|
72
|
+
expect(describeExitCode(null).text).toBe('unknown');
|
|
73
|
+
expect(describeExitCode(undefined).signal).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('is re-exported from isolation-log-utils so both callers share it', () => {
|
|
77
|
+
expect(reExported).toBe(describeExitCode);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('issue #171: docker timestamp and lifetime helpers', () => {
|
|
82
|
+
it('rejects the docker zero-time sentinel', () => {
|
|
83
|
+
expect(normalizeDockerTimestamp('0001-01-01T00:00:00Z')).toBeNull();
|
|
84
|
+
expect(normalizeDockerTimestamp('')).toBeNull();
|
|
85
|
+
expect(normalizeDockerTimestamp('unknown')).toBeNull();
|
|
86
|
+
expect(normalizeDockerTimestamp('<no value>')).toBeNull();
|
|
87
|
+
expect(normalizeDockerTimestamp('2026-09-15T22:21:46.740817278Z')).toBe(
|
|
88
|
+
'2026-09-15T22:21:46.740817278Z'
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('computes the container lifetime', () => {
|
|
93
|
+
expect(
|
|
94
|
+
formatLifetime(
|
|
95
|
+
'2026-09-15T22:21:40.942007645Z',
|
|
96
|
+
'2026-09-15T22:21:46.740817278Z'
|
|
97
|
+
)
|
|
98
|
+
).toBe('5.798s');
|
|
99
|
+
expect(formatLifetime('unknown', 'unknown')).toBeNull();
|
|
100
|
+
expect(
|
|
101
|
+
formatLifetime('2026-09-15T22:21:46.000Z', '0001-01-01T00:00:00Z')
|
|
102
|
+
).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('issue #171.2: the kept-container log carries the post-mortem', () => {
|
|
107
|
+
it('formats the block in the documented shape', () => {
|
|
108
|
+
const block = formatContainerPostMortem({
|
|
109
|
+
containerName: 'demo',
|
|
110
|
+
exitCode: 137,
|
|
111
|
+
oomKilled: false,
|
|
112
|
+
startedAt: '2026-09-15T22:21:40.942007645Z',
|
|
113
|
+
finishedAt: '2026-09-15T22:21:46.740817278Z',
|
|
114
|
+
error: '',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(block).toContain('=== Container post-mortem ===');
|
|
118
|
+
expect(block).toContain('Exit Code: 137 (SIGKILL - 128+9)');
|
|
119
|
+
expect(block).toContain('OOMKilled: false');
|
|
120
|
+
expect(block).toContain('StartedAt: 2026-09-15T22:21:40.942007645Z');
|
|
121
|
+
expect(block).toContain('FinishedAt: 2026-09-15T22:21:46.740817278Z');
|
|
122
|
+
expect(block).toContain('Lifetime: 5.798s');
|
|
123
|
+
expect(block).toContain('Error: (none)');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('renders unknown facts instead of empty fields', () => {
|
|
127
|
+
const block = formatContainerPostMortem({
|
|
128
|
+
containerName: 'demo',
|
|
129
|
+
exitCode: null,
|
|
130
|
+
oomKilled: null,
|
|
131
|
+
startedAt: '0001-01-01T00:00:00Z',
|
|
132
|
+
finishedAt: '',
|
|
133
|
+
error: 'OCI runtime create failed',
|
|
134
|
+
});
|
|
135
|
+
expect(block).toContain('Exit Code: unknown');
|
|
136
|
+
expect(block).toContain('StartedAt: unknown');
|
|
137
|
+
expect(block).toContain('Lifetime: unknown');
|
|
138
|
+
expect(block).toContain('Error: OCI runtime create failed');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('issue #171.3: the removal path states the same facts', () => {
|
|
143
|
+
it('formats a single line', () => {
|
|
144
|
+
expect(
|
|
145
|
+
formatContainerRemovalNote({
|
|
146
|
+
containerName: 'demo',
|
|
147
|
+
exitCode: 137,
|
|
148
|
+
oomKilled: false,
|
|
149
|
+
startedAt: '2026-09-15T22:21:40.942007645Z',
|
|
150
|
+
finishedAt: '2026-09-15T22:21:46.740817278Z',
|
|
151
|
+
})
|
|
152
|
+
).toBe(
|
|
153
|
+
'Container removed: demo (exit 137, SIGKILL, lifetime 5.798s, oomKilled=false)\n'
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('issue #171.1: the watcher collects the full state in one inspect', () => {
|
|
159
|
+
it('asks docker for exit code, oom flag and both timestamps', () => {
|
|
160
|
+
expect(DOCKER_STATE_INSPECT_FORMAT).toContain('{{.State.ExitCode}}');
|
|
161
|
+
expect(DOCKER_STATE_INSPECT_FORMAT).toContain('{{.State.OOMKilled}}');
|
|
162
|
+
expect(DOCKER_STATE_INSPECT_FORMAT).toContain('{{.State.StartedAt}}');
|
|
163
|
+
expect(DOCKER_STATE_INSPECT_FORMAT).toContain('{{.State.FinishedAt}}');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('issue #171: the generated watcher shell writes the facts', () => {
|
|
168
|
+
// The watcher body is a POSIX `sh` fragment that docker-cleanup.js runs with
|
|
169
|
+
// `sh -c`. Windows has no `/bin/sh`, so there is nothing to assert there; the
|
|
170
|
+
// pure formatters above cover the same rendering on every platform.
|
|
171
|
+
const needsPosixShell = () => {
|
|
172
|
+
if (process.platform !== 'win32') {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
console.log(' Skipping: the watcher body needs a POSIX /bin/sh');
|
|
176
|
+
return true;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
function runWatcherBody(snippetFactory, dockerState, dockerError, dateStub) {
|
|
180
|
+
const dir = makeTempDir('watcher-171-');
|
|
181
|
+
const logPath = path.join(dir, 'run.log');
|
|
182
|
+
const snippet = snippetFactory(logPath);
|
|
183
|
+
const binDir = path.join(dir, 'bin');
|
|
184
|
+
fs.mkdirSync(binDir);
|
|
185
|
+
if (dateStub) {
|
|
186
|
+
const datePath = path.join(binDir, 'date');
|
|
187
|
+
fs.writeFileSync(datePath, dateStub, 'utf8');
|
|
188
|
+
fs.chmodSync(datePath, 0o755);
|
|
189
|
+
}
|
|
190
|
+
const dockerPath = path.join(binDir, 'docker');
|
|
191
|
+
fs.writeFileSync(
|
|
192
|
+
dockerPath,
|
|
193
|
+
[
|
|
194
|
+
'#!/bin/sh',
|
|
195
|
+
'[ "$1" = "inspect" ] || exit 1',
|
|
196
|
+
'case "$3" in',
|
|
197
|
+
` *State.Error*) printf '%s\\n' ${JSON.stringify(dockerError || '')} ;;`,
|
|
198
|
+
` *) printf '%s\\n' ${JSON.stringify(dockerState)} ;;`,
|
|
199
|
+
'esac',
|
|
200
|
+
'',
|
|
201
|
+
].join('\n'),
|
|
202
|
+
'utf8'
|
|
203
|
+
);
|
|
204
|
+
fs.chmodSync(dockerPath, 0o755);
|
|
205
|
+
|
|
206
|
+
spawnSync('/bin/sh', ['-c', snippet], {
|
|
207
|
+
encoding: 'utf8',
|
|
208
|
+
env: {
|
|
209
|
+
...process.env,
|
|
210
|
+
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
|
|
211
|
+
},
|
|
212
|
+
timeout: 20000,
|
|
213
|
+
});
|
|
214
|
+
return fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it('appends the post-mortem block for a kept container', () => {
|
|
218
|
+
if (needsPosixShell()) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const log = runWatcherBody(
|
|
222
|
+
(logPath) =>
|
|
223
|
+
`${buildDockerStateSnippet('demo')}; ${buildDockerPostMortemSnippet(
|
|
224
|
+
'demo',
|
|
225
|
+
`'${logPath}'`
|
|
226
|
+
)}`,
|
|
227
|
+
'137 false 2026-09-15T22:21:40.942007645Z 2026-09-15T22:21:46.740817278Z'
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
expect(log).toContain('=== Container post-mortem ===');
|
|
231
|
+
expect(log).toContain('Exit Code: 137 (SIGKILL - 128+9)');
|
|
232
|
+
expect(log).toContain('OOMKilled: false');
|
|
233
|
+
expect(log).toContain('StartedAt: 2026-09-15T22:21:40.942007645Z');
|
|
234
|
+
expect(log).toContain('FinishedAt: 2026-09-15T22:21:46.740817278Z');
|
|
235
|
+
expect(log).toContain('Lifetime: 5.798s');
|
|
236
|
+
expect(log).toContain('Error: (none)');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('appends a one line note for a removed container', () => {
|
|
240
|
+
if (needsPosixShell()) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const log = runWatcherBody(
|
|
244
|
+
(logPath) =>
|
|
245
|
+
`${buildDockerStateSnippet('demo')}; ${buildDockerRemovalNoteSnippet(
|
|
246
|
+
'demo',
|
|
247
|
+
`'${logPath}'`
|
|
248
|
+
)}`,
|
|
249
|
+
'0 false 2026-09-15T22:21:40.000000000Z 2026-09-15T22:21:41.500000000Z'
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(log).toContain(
|
|
253
|
+
'Container removed: demo (exit 0, lifetime 1.500s, oomKilled=false)'
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// A `date` shaped like BSD's: it rejects GNU's `-d` and `%N` and only parses
|
|
258
|
+
// through `-j -f`. That makes the fallback branch reachable on Linux, where
|
|
259
|
+
// it would otherwise never execute.
|
|
260
|
+
const BSD_DATE_STUB = [
|
|
261
|
+
'#!/bin/sh',
|
|
262
|
+
'if [ "$1" = "-u" ] && [ "$2" = "-j" ] && [ "$3" = "-f" ]; then',
|
|
263
|
+
// On macOS /bin/date is already BSD, so it understands these flags as they
|
|
264
|
+
// stand; on Linux it is GNU and rejects them, so translate instead.
|
|
265
|
+
' /bin/date "$@" 2>/dev/null && exit 0',
|
|
266
|
+
' exec /bin/date -u -d "$(echo "$5" | tr \'T\' \' \')" "$6"',
|
|
267
|
+
'fi',
|
|
268
|
+
'exit 1',
|
|
269
|
+
'',
|
|
270
|
+
].join('\n');
|
|
271
|
+
|
|
272
|
+
it('keeps the lifetime exact on a host whose date is BSD', () => {
|
|
273
|
+
if (needsPosixShell()) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const log = runWatcherBody(
|
|
277
|
+
(logPath) =>
|
|
278
|
+
`${buildDockerStateSnippet('demo')}; ${buildDockerPostMortemSnippet(
|
|
279
|
+
'demo',
|
|
280
|
+
`'${logPath}'`
|
|
281
|
+
)}`,
|
|
282
|
+
'137 false 2026-09-15T22:21:40.942007645Z 2026-09-15T22:21:46.740817278Z',
|
|
283
|
+
'',
|
|
284
|
+
BSD_DATE_STUB
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// Truncating both timestamps to whole seconds would report 6.000s here.
|
|
288
|
+
expect(log).toContain('Lifetime: 5.798s');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('keeps sub-second lifetimes exact on a BSD date host', () => {
|
|
292
|
+
if (needsPosixShell()) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const log = runWatcherBody(
|
|
296
|
+
(logPath) =>
|
|
297
|
+
`${buildDockerStateSnippet('demo')}; ${buildDockerRemovalNoteSnippet(
|
|
298
|
+
'demo',
|
|
299
|
+
`'${logPath}'`
|
|
300
|
+
)}`,
|
|
301
|
+
'0 false 2026-09-15T22:21:40.000000000Z 2026-09-15T22:21:41.500000000Z',
|
|
302
|
+
'',
|
|
303
|
+
BSD_DATE_STUB
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Truncating would collapse this to 1.000s.
|
|
307
|
+
expect(log).toContain(
|
|
308
|
+
'Container removed: demo (exit 0, lifetime 1.500s, oomKilled=false)'
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('survives an inspect failure without writing garbage', () => {
|
|
313
|
+
if (needsPosixShell()) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const dir = makeTempDir('watcher-171-fail-');
|
|
317
|
+
const logPath = path.join(dir, 'run.log');
|
|
318
|
+
const binDir = path.join(dir, 'bin');
|
|
319
|
+
fs.mkdirSync(binDir);
|
|
320
|
+
fs.writeFileSync(
|
|
321
|
+
path.join(binDir, 'docker'),
|
|
322
|
+
['#!/bin/sh', 'exit 1', ''].join('\n'),
|
|
323
|
+
'utf8'
|
|
324
|
+
);
|
|
325
|
+
fs.chmodSync(path.join(binDir, 'docker'), 0o755);
|
|
326
|
+
|
|
327
|
+
const snippet = `${buildDockerStateSnippet(
|
|
328
|
+
'demo'
|
|
329
|
+
)}; ${buildDockerPostMortemSnippet('demo', `'${logPath}'`)}`;
|
|
330
|
+
const result = spawnSync('/bin/sh', ['-c', snippet], {
|
|
331
|
+
encoding: 'utf8',
|
|
332
|
+
env: {
|
|
333
|
+
...process.env,
|
|
334
|
+
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
|
|
335
|
+
},
|
|
336
|
+
timeout: 20000,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(result.status).toBe(0);
|
|
340
|
+
const log = fs.readFileSync(logPath, 'utf8');
|
|
341
|
+
expect(log).toContain('Exit Code: -1');
|
|
342
|
+
expect(log).toContain('StartedAt: unknown');
|
|
343
|
+
expect(log).toContain('Lifetime: unknown');
|
|
344
|
+
expect(log).not.toContain('0001-01-01');
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe('issue #171: the completion script wires the facts into every path', () => {
|
|
349
|
+
it('collects the full state and writes a post-mortem on the kept path', () => {
|
|
350
|
+
const script = buildDetachedDockerCompletionScript(
|
|
351
|
+
'demo',
|
|
352
|
+
'keep-on-fail',
|
|
353
|
+
'/tmp/demo.log'
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
expect(script).toContain('{{.State.StartedAt}}');
|
|
357
|
+
expect(script).toContain('{{.State.FinishedAt}}');
|
|
358
|
+
expect(script).toContain('{{.State.Error}}');
|
|
359
|
+
expect(script).toContain('=== Container post-mortem ===');
|
|
360
|
+
expect(script).toContain('Container removed:');
|
|
361
|
+
expect(script).toContain('Container kept for investigation');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('writes a post-mortem even when the container is always kept', () => {
|
|
365
|
+
const script = buildDetachedDockerCompletionScript(
|
|
366
|
+
'demo',
|
|
367
|
+
'keep',
|
|
368
|
+
'/tmp/demo.log'
|
|
369
|
+
);
|
|
370
|
+
expect(script).toContain('=== Container post-mortem ===');
|
|
371
|
+
// The only `docker rm -f` left is the copy-paste hint in the kept message.
|
|
372
|
+
expect(script).not.toContain("docker rm -f 'demo' >>");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('writes the removal note when the container is always removed', () => {
|
|
376
|
+
const script = buildDetachedDockerCompletionScript(
|
|
377
|
+
'demo',
|
|
378
|
+
'always',
|
|
379
|
+
'/tmp/demo.log'
|
|
380
|
+
);
|
|
381
|
+
expect(script).toContain('Container removed:');
|
|
382
|
+
expect(script).toContain("docker rm -f 'demo' >>");
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A fake `docker` that answers the two `inspect` templates
|
|
388
|
+
* `readDockerContainerState()` issues: the whitespace-separated state line and
|
|
389
|
+
* the free-form `State.Error`.
|
|
390
|
+
*
|
|
391
|
+
* Windows gets a `.cmd` batch stub: `spawnSync()` there cannot execute an
|
|
392
|
+
* extensionless `#!/bin/sh` script, and the attached reader is a plain
|
|
393
|
+
* `spawnSync()` that must keep working on every platform.
|
|
394
|
+
*/
|
|
395
|
+
function withFakeDockerState({ state, error = '' }, fn) {
|
|
396
|
+
const fakeBin = makeTempDir('fake-docker-171-');
|
|
397
|
+
const isWindows = process.platform === 'win32';
|
|
398
|
+
const dockerPath = path.join(fakeBin, isWindows ? 'docker.cmd' : 'docker');
|
|
399
|
+
fs.writeFileSync(
|
|
400
|
+
dockerPath,
|
|
401
|
+
isWindows
|
|
402
|
+
? [
|
|
403
|
+
'@echo off',
|
|
404
|
+
'if not "%1"=="inspect" exit /b 1',
|
|
405
|
+
// `%*`, not `%3`: `shell: true` on Windows passes the argument
|
|
406
|
+
// vector verbatim, so a template containing spaces lands in several
|
|
407
|
+
// positional parameters.
|
|
408
|
+
'echo %* | findstr /C:"State.Error" >nul',
|
|
409
|
+
'if %errorlevel%==0 (',
|
|
410
|
+
// `echo` with nothing after it prints "ECHO is on."; `echo.` is the
|
|
411
|
+
// batch idiom for an empty line, which is what an empty State.Error is.
|
|
412
|
+
` echo${error ? ` ${error}` : '.'}`,
|
|
413
|
+
') else (',
|
|
414
|
+
` echo ${state}`,
|
|
415
|
+
')',
|
|
416
|
+
'exit /b 0',
|
|
417
|
+
'',
|
|
418
|
+
].join('\r\n')
|
|
419
|
+
: [
|
|
420
|
+
'#!/bin/sh',
|
|
421
|
+
'[ "$1" = "inspect" ] || exit 1',
|
|
422
|
+
'case "$3" in',
|
|
423
|
+
` *State.Error*) printf '%s\\n' ${JSON.stringify(error)} ;;`,
|
|
424
|
+
` *) printf '%s\\n' ${JSON.stringify(state)} ;;`,
|
|
425
|
+
'esac',
|
|
426
|
+
'',
|
|
427
|
+
].join('\n'),
|
|
428
|
+
'utf8'
|
|
429
|
+
);
|
|
430
|
+
fs.chmodSync(dockerPath, 0o755);
|
|
431
|
+
const original = process.env.START_DOCKER_BIN;
|
|
432
|
+
process.env.START_DOCKER_BIN = dockerPath;
|
|
433
|
+
try {
|
|
434
|
+
return fn();
|
|
435
|
+
} finally {
|
|
436
|
+
if (original === undefined) {
|
|
437
|
+
delete process.env.START_DOCKER_BIN;
|
|
438
|
+
} else {
|
|
439
|
+
process.env.START_DOCKER_BIN = original;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
describe('issue #171: the attached path records the same facts', () => {
|
|
445
|
+
it('reads every documented fact in one inspect', () => {
|
|
446
|
+
const state = withFakeDockerState(
|
|
447
|
+
{
|
|
448
|
+
state:
|
|
449
|
+
'137 false 2026-09-15T22:21:40.942007645Z 2026-09-15T22:21:46.740817278Z',
|
|
450
|
+
},
|
|
451
|
+
() => readDockerContainerState('demo')
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
expect(state).toEqual({
|
|
455
|
+
containerName: 'demo',
|
|
456
|
+
exitCode: 137,
|
|
457
|
+
oomKilled: false,
|
|
458
|
+
startedAt: '2026-09-15T22:21:40.942007645Z',
|
|
459
|
+
finishedAt: '2026-09-15T22:21:46.740817278Z',
|
|
460
|
+
error: null,
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('rejects the zero-time sentinel for a container that never started', () => {
|
|
465
|
+
const state = withFakeDockerState(
|
|
466
|
+
{
|
|
467
|
+
state: '125 false 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z',
|
|
468
|
+
error: 'no such file or directory',
|
|
469
|
+
},
|
|
470
|
+
() => readDockerContainerState('demo')
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
expect(state.startedAt).toBeNull();
|
|
474
|
+
expect(state.finishedAt).toBeNull();
|
|
475
|
+
expect(state.error).toBe('no such file or directory');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('returns null when the container cannot be inspected at all', () => {
|
|
479
|
+
const fakeBin = makeTempDir('fake-docker-gone-171-');
|
|
480
|
+
const dockerPath = path.join(fakeBin, 'docker');
|
|
481
|
+
fs.writeFileSync(dockerPath, '#!/bin/sh\nexit 1\n', 'utf8');
|
|
482
|
+
fs.chmodSync(dockerPath, 0o755);
|
|
483
|
+
const original = process.env.START_DOCKER_BIN;
|
|
484
|
+
process.env.START_DOCKER_BIN = dockerPath;
|
|
485
|
+
try {
|
|
486
|
+
expect(readDockerContainerState('demo')).toBeNull();
|
|
487
|
+
} finally {
|
|
488
|
+
if (original === undefined) {
|
|
489
|
+
delete process.env.START_DOCKER_BIN;
|
|
490
|
+
} else {
|
|
491
|
+
process.env.START_DOCKER_BIN = original;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('appends the post-mortem block to a kept attached run log', () => {
|
|
497
|
+
const dir = makeTempDir('attached-kept-171-');
|
|
498
|
+
const logPath = path.join(dir, 'run.log');
|
|
499
|
+
fs.writeFileSync(logPath, 'output\n', 'utf8');
|
|
500
|
+
|
|
501
|
+
const message = recordAttachedDockerPostMortem({
|
|
502
|
+
containerName: 'demo',
|
|
503
|
+
state: {
|
|
504
|
+
exitCode: 137,
|
|
505
|
+
oomKilled: false,
|
|
506
|
+
startedAt: '2026-09-15T22:21:40.942007645Z',
|
|
507
|
+
finishedAt: '2026-09-15T22:21:46.740817278Z',
|
|
508
|
+
error: null,
|
|
509
|
+
},
|
|
510
|
+
logPath,
|
|
511
|
+
removed: false,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const log = fs.readFileSync(logPath, 'utf8');
|
|
515
|
+
expect(log).toContain('=== Container post-mortem ===');
|
|
516
|
+
expect(log).toContain('Exit Code: 137 (SIGKILL - 128+9)');
|
|
517
|
+
expect(log).toContain('Lifetime: 5.798s');
|
|
518
|
+
expect(message).toContain('=== Container post-mortem ===');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('appends the one line note when the attached container is removed', () => {
|
|
522
|
+
const dir = makeTempDir('attached-removed-171-');
|
|
523
|
+
const logPath = path.join(dir, 'run.log');
|
|
524
|
+
fs.writeFileSync(logPath, 'output\n', 'utf8');
|
|
525
|
+
|
|
526
|
+
const message = recordAttachedDockerPostMortem({
|
|
527
|
+
containerName: 'demo',
|
|
528
|
+
state: {
|
|
529
|
+
exitCode: 0,
|
|
530
|
+
oomKilled: false,
|
|
531
|
+
startedAt: '2026-09-15T22:21:40.942007645Z',
|
|
532
|
+
finishedAt: '2026-09-15T22:21:46.740817278Z',
|
|
533
|
+
error: null,
|
|
534
|
+
},
|
|
535
|
+
logPath,
|
|
536
|
+
removed: true,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain(
|
|
540
|
+
'Container removed: demo (exit 0, lifetime 5.798s, oomKilled=false)'
|
|
541
|
+
);
|
|
542
|
+
expect(message).toBe(
|
|
543
|
+
'\nContainer removed: demo (exit 0, lifetime 5.798s, oomKilled=false)'
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('stays silent when docker could not be inspected', () => {
|
|
548
|
+
const dir = makeTempDir('attached-silent-171-');
|
|
549
|
+
const logPath = path.join(dir, 'run.log');
|
|
550
|
+
fs.writeFileSync(logPath, 'output\n', 'utf8');
|
|
551
|
+
|
|
552
|
+
expect(
|
|
553
|
+
recordAttachedDockerPostMortem({
|
|
554
|
+
containerName: 'demo',
|
|
555
|
+
state: null,
|
|
556
|
+
logPath,
|
|
557
|
+
removed: false,
|
|
558
|
+
})
|
|
559
|
+
).toBe('');
|
|
560
|
+
expect(fs.readFileSync(logPath, 'utf8')).toBe('output\n');
|
|
561
|
+
});
|
|
562
|
+
});
|