start-command 0.38.0 → 0.39.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 +28 -0
- package/README.md +4 -2
- package/bunfig.toml +4 -0
- package/eslint.config.mjs +1 -0
- package/package.json +4 -4
- package/src/lib/args-parser.js +9 -0
- package/src/lib/cgroup-memory.js +120 -24
- package/src/lib/command-builder.js +4 -0
- package/src/lib/container-removal.js +4 -5
- package/src/lib/detached-finalize.js +1 -0
- package/src/lib/docker-init-option.js +65 -0
- package/src/lib/docker-inspect-batch.js +176 -0
- package/src/lib/docker-resource-options.js +21 -2
- package/src/lib/docker-runtime-args.js +12 -3
- package/src/lib/execution-control.js +8 -6
- package/src/lib/execution-recovery.js +4 -1
- package/src/lib/execution-resume.js +4 -1
- package/src/lib/execution-store.js +19 -1
- package/src/lib/exit-evidence.js +139 -7
- package/src/lib/output-blocks.js +11 -0
- package/src/lib/resume-resources.js +2 -2
- package/src/lib/screen-isolation.js +72 -42
- package/src/lib/status-formatter.js +34 -130
- package/src/lib/status-probe.js +149 -0
- package/src/lib/store-decode-cache.js +141 -0
- package/src/lib/usage.js +3 -0
- package/test/cargo-lock-version.mjs +121 -0
- package/test/check-rust-coverage.mjs +71 -0
- package/test/check-web-archive.mjs +154 -0
- package/test/ci-workflow-invariants.js +204 -0
- package/test/command-builder-docker-cleanup.js +24 -0
- package/test/command-exit-codes.mjs +91 -0
- package/test/docker-runtime-options.js +2 -0
- package/test/docker-snapshot-safety.js +39 -0
- package/test/execution-resume.js +2 -0
- package/test/fixtures/fake-npm.sh +64 -0
- package/test/fixtures/lychee-report.md +39 -0
- package/test/isolation.js +32 -0
- package/test/javascript-first-ci.js +69 -0
- package/test/links-workflow.mjs +127 -0
- package/test/lint-gate.mjs +56 -0
- package/test/npm-publish.mjs +181 -0
- package/test/recheck-broken-links.mjs +452 -0
- package/test/regression-176.js +2 -0
- package/test/regression-187.js +2 -0
- package/test/regression-191.js +4 -0
- package/test/regression-193.js +2 -0
- package/test/regression-198.js +2 -0
- package/test/regression-207.js +8 -2
- package/test/regression-224.js +2 -0
- package/test/regression-236.js +250 -0
- package/test/regression-238.js +129 -0
- package/test/regression-241.js +254 -0
- package/test/regression-242.js +241 -0
- package/test/regression-243.js +493 -0
- package/test/regression-244.js +471 -0
- package/test/regression-247.js +140 -0
- package/test/release-push-retry.mjs +172 -0
- package/test/run-js-tests.mjs +15 -3
- package/test/setup/private-snapshot-lock.js +17 -0
- package/test/shared-golden.js +29 -2
- package/test/transient-links.mjs +139 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Own the snapshot lock when run without the default preloads.
|
|
2
|
+
require('./setup/private-snapshot-lock');
|
|
3
|
+
const { it, expect } = require('bun:test');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
8
|
+
const {
|
|
9
|
+
resolveResourceOptions,
|
|
10
|
+
} = require('../src/lib/docker-resource-options');
|
|
11
|
+
const {
|
|
12
|
+
buildDockerRuntimeArgs,
|
|
13
|
+
buildDockerRuntimeMetadata,
|
|
14
|
+
} = require('../src/lib/docker-runtime-args');
|
|
15
|
+
const {
|
|
16
|
+
ExecutionRecord,
|
|
17
|
+
ExecutionStore,
|
|
18
|
+
} = require('../src/lib/execution-store');
|
|
19
|
+
const { formatRecord } = require('../src/lib/status-formatter');
|
|
20
|
+
const { resumeExecution } = require('../src/lib/execution-resume');
|
|
21
|
+
|
|
22
|
+
it('parses the reported command and forwards both limits to Docker', () => {
|
|
23
|
+
for (const flag of [
|
|
24
|
+
['--memory-reservation', '64m'],
|
|
25
|
+
['--memory-reservation=64m'],
|
|
26
|
+
]) {
|
|
27
|
+
const options = parseArgs([
|
|
28
|
+
'--isolated',
|
|
29
|
+
'docker',
|
|
30
|
+
'--image',
|
|
31
|
+
'alpine:3.20',
|
|
32
|
+
'--memory',
|
|
33
|
+
'256m',
|
|
34
|
+
...flag,
|
|
35
|
+
'--',
|
|
36
|
+
'echo',
|
|
37
|
+
'hi',
|
|
38
|
+
]).wrapperOptions;
|
|
39
|
+
expect(options.memoryReservation).toBe('64m');
|
|
40
|
+
const resolved = resolveResourceOptions(options);
|
|
41
|
+
const argv = buildDockerRuntimeArgs(resolved);
|
|
42
|
+
expect(argv).toContain('--memory=268435456');
|
|
43
|
+
expect(argv).toContain('--memory-reservation=67108864');
|
|
44
|
+
const metadata = buildDockerRuntimeMetadata({ ...options, ...resolved });
|
|
45
|
+
const record = ExecutionRecord.fromObject(
|
|
46
|
+
new ExecutionRecord({ command: 'echo hi', options: metadata }).toObject()
|
|
47
|
+
);
|
|
48
|
+
expect(record.options.resourceLimitSpecs.memoryReservation).toBe('64m');
|
|
49
|
+
expect(record.options.resolvedLimits.memoryReservation).toBe(67108864);
|
|
50
|
+
for (const format of ['json', 'text', 'links-notation']) {
|
|
51
|
+
expect(formatRecord(record, format)).toContain('memoryReservation');
|
|
52
|
+
expect(formatRecord(record, format)).toContain('67108864');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('resolves reservation percentages from daemon RAM once, including ranges', () => {
|
|
58
|
+
let calls = 0;
|
|
59
|
+
const resolved = resolveResourceOptions(
|
|
60
|
+
{ memory: '50%', memoryReservation: '10%-20%' },
|
|
61
|
+
(_, argv) => {
|
|
62
|
+
calls++;
|
|
63
|
+
expect(argv[0]).toBe('info');
|
|
64
|
+
return {
|
|
65
|
+
success: true,
|
|
66
|
+
stdout: JSON.stringify({ MemTotal: 10 * 1024 ** 3, NCPU: 8 }),
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
() => 0.5
|
|
70
|
+
);
|
|
71
|
+
expect(calls).toBe(1);
|
|
72
|
+
expect(resolved.resolvedLimits.memoryReservation).toBe(1610612736);
|
|
73
|
+
expect(resolved.resourceLimitSpecs.memoryReservation).toBe('10%-20%');
|
|
74
|
+
expect(
|
|
75
|
+
resolveResourceOptions({ resourceLimits: resolved.resourceLimits }, () => {
|
|
76
|
+
throw Error('must preserve resolved limits');
|
|
77
|
+
}).resourceLimits
|
|
78
|
+
).toEqual(resolved.resourceLimits);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('rejects equal or larger reservations against new and inherited hard caps', () => {
|
|
82
|
+
for (const options of [
|
|
83
|
+
{ memory: '256m', memoryReservation: '512m' },
|
|
84
|
+
{ memory: '256m', memoryReservation: '256m' },
|
|
85
|
+
{ memoryReservation: '512m', resourceLimits: ['--memory=256m'] },
|
|
86
|
+
{
|
|
87
|
+
memory: '32m',
|
|
88
|
+
resourceLimits: ['--memory=256m', '--memory-reservation=64m'],
|
|
89
|
+
},
|
|
90
|
+
]) {
|
|
91
|
+
expect(() => resolveResourceOptions(options)).toThrow(
|
|
92
|
+
'memory-reservation must be less than memory'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
expect(
|
|
96
|
+
resolveResourceOptions({ memoryReservation: '64m' }).resourceLimits
|
|
97
|
+
).toEqual(['--memory-reservation=67108864']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('validates reservation syntax, missing values and Docker isolation', () => {
|
|
101
|
+
for (const value of ['0m', '0%', '101%', '10watts', '20%-10%', '']) {
|
|
102
|
+
expect(() =>
|
|
103
|
+
parseArgs([
|
|
104
|
+
'--isolated',
|
|
105
|
+
'docker',
|
|
106
|
+
`--memory-reservation=${value}`,
|
|
107
|
+
'--',
|
|
108
|
+
'true',
|
|
109
|
+
])
|
|
110
|
+
).toThrow();
|
|
111
|
+
}
|
|
112
|
+
expect(() =>
|
|
113
|
+
parseArgs(['--isolated', 'docker', '--memory-reservation'])
|
|
114
|
+
).toThrow();
|
|
115
|
+
expect(() => parseArgs(['--memory-reservation=64m', '--', 'true'])).toThrow(
|
|
116
|
+
'Resource options require Docker'
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('forwards reservations through a nested Docker isolation level', () => {
|
|
121
|
+
const options = parseArgs([
|
|
122
|
+
'--isolated',
|
|
123
|
+
'screen docker',
|
|
124
|
+
'--memory',
|
|
125
|
+
'256m',
|
|
126
|
+
'--memory-reservation',
|
|
127
|
+
'64m',
|
|
128
|
+
'--',
|
|
129
|
+
'work',
|
|
130
|
+
]).wrapperOptions;
|
|
131
|
+
expect(
|
|
132
|
+
require('../src/lib/command-builder').buildNextLevelCommand(options, 'work')
|
|
133
|
+
).toContain('--memory-reservation 64m');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
for (const mode of ['start', 'snapshot', 'missing', 'override', 'invalid']) {
|
|
137
|
+
it(`preserves or overrides the reservation on ${mode} resume`, async () => {
|
|
138
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-236-'));
|
|
139
|
+
try {
|
|
140
|
+
const store = new ExecutionStore({ appFolder: dir, useLinks: false });
|
|
141
|
+
const resolved = resolveResourceOptions({
|
|
142
|
+
memory: '256m',
|
|
143
|
+
memoryReservation: '64m',
|
|
144
|
+
});
|
|
145
|
+
const record = new ExecutionRecord({
|
|
146
|
+
command: 'work',
|
|
147
|
+
status: 'executed',
|
|
148
|
+
logPath: path.join(dir, 'run.log'),
|
|
149
|
+
options: {
|
|
150
|
+
isolated: 'docker',
|
|
151
|
+
isolationMode: 'detached',
|
|
152
|
+
sessionName: 'task',
|
|
153
|
+
image: 'alpine',
|
|
154
|
+
...resolved,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
store.save(record);
|
|
158
|
+
const calls = [];
|
|
159
|
+
const runner = (_, args) => {
|
|
160
|
+
calls.push(args);
|
|
161
|
+
if (args[0] === 'info') {
|
|
162
|
+
return {
|
|
163
|
+
success: true,
|
|
164
|
+
stdout: JSON.stringify({
|
|
165
|
+
MemTotal: 1024 ** 3,
|
|
166
|
+
NCPU: 4,
|
|
167
|
+
DockerRootDir: os.tmpdir(),
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (args.includes('--size')) {
|
|
172
|
+
return { success: true, stdout: '1024' };
|
|
173
|
+
}
|
|
174
|
+
if (args[0] === 'inspect') {
|
|
175
|
+
return {
|
|
176
|
+
success: true,
|
|
177
|
+
stdout: JSON.stringify({
|
|
178
|
+
Memory: 268435456,
|
|
179
|
+
MemorySwap: 268435456,
|
|
180
|
+
MemoryReservation: 67108864,
|
|
181
|
+
}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return { success: true, stdout: 'id' };
|
|
185
|
+
};
|
|
186
|
+
const result = await resumeExecution(store, record.uuid, {
|
|
187
|
+
command: mode === 'snapshot' ? 'new' : null,
|
|
188
|
+
resourceOptions:
|
|
189
|
+
mode === 'override'
|
|
190
|
+
? { memoryReservation: '32m' }
|
|
191
|
+
: mode === 'invalid'
|
|
192
|
+
? { memory: '32m' }
|
|
193
|
+
: {},
|
|
194
|
+
runIsolated: async (_backend, _command, options) => {
|
|
195
|
+
calls.push(['run', ...options.resourceLimits]);
|
|
196
|
+
return { success: true };
|
|
197
|
+
},
|
|
198
|
+
snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
|
|
199
|
+
runner,
|
|
200
|
+
probe: () => ({
|
|
201
|
+
alive: false,
|
|
202
|
+
state: mode === 'missing' ? 'missing' : 'stopped',
|
|
203
|
+
}),
|
|
204
|
+
startWatcher: () => true,
|
|
205
|
+
});
|
|
206
|
+
expect(result.success).toBe(mode !== 'invalid');
|
|
207
|
+
if (mode === 'invalid') {
|
|
208
|
+
expect(result.error).toContain(
|
|
209
|
+
'memory-reservation must be less than memory'
|
|
210
|
+
);
|
|
211
|
+
expect(
|
|
212
|
+
calls.some((a) => ['start', 'run', 'update', 'commit'].includes(a[0]))
|
|
213
|
+
).toBe(false);
|
|
214
|
+
} else {
|
|
215
|
+
const current = store.get(record.uuid);
|
|
216
|
+
const bytes = mode === 'override' ? 33554432 : 67108864;
|
|
217
|
+
expect(current.options.resolvedLimits.memoryReservation).toBe(bytes);
|
|
218
|
+
expect(current.options.resourceLimitSpecs.memoryReservation).toBe(
|
|
219
|
+
mode === 'override' ? '32m' : '64m'
|
|
220
|
+
);
|
|
221
|
+
if (mode === 'override') {
|
|
222
|
+
expect(calls.find((a) => a[0] === 'update')).toContain(
|
|
223
|
+
`--memory-reservation=${bytes}`
|
|
224
|
+
);
|
|
225
|
+
expect(calls.findIndex((a) => a[0] === 'update')).toBeLessThan(
|
|
226
|
+
calls.findIndex((a) => a[0] === 'start')
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (mode === 'snapshot' || mode === 'missing') {
|
|
230
|
+
const limits = calls.find((a) => a[0] === 'run');
|
|
231
|
+
expect(
|
|
232
|
+
limits.some((s) => /^--memory-reservation=(64m|67108864)$/.test(s))
|
|
233
|
+
).toBe(true);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} finally {
|
|
237
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
it('refuses reservation overrides when resuming a non-Docker execution', () => {
|
|
243
|
+
expect(() =>
|
|
244
|
+
require('../src/lib/resume-resources').prepareResumeResources(
|
|
245
|
+
{ options: { isolated: 'screen' } },
|
|
246
|
+
{ state: 'stopped' },
|
|
247
|
+
{ memoryReservation: '64m' }
|
|
248
|
+
)
|
|
249
|
+
).toThrow('Resource overrides require a Docker execution');
|
|
250
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const { describe, it, expect } = require('bun:test');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const {
|
|
7
|
+
buildCgroupSamplerStartSnippet,
|
|
8
|
+
buildCgroupSamplerStopSnippet,
|
|
9
|
+
buildCgroupMemoryLogSnippet,
|
|
10
|
+
} = require('../src/lib/cgroup-memory');
|
|
11
|
+
|
|
12
|
+
function sample(mode, interval, clockStep = '5') {
|
|
13
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sampler-238-'));
|
|
14
|
+
try {
|
|
15
|
+
const script = path.join(dir, 'sample.sh');
|
|
16
|
+
fs.writeFileSync(
|
|
17
|
+
script,
|
|
18
|
+
[
|
|
19
|
+
buildCgroupSamplerStartSnippet("task '238'"),
|
|
20
|
+
'wait "$__start_command_cgroup_sampler"',
|
|
21
|
+
mode === 'final'
|
|
22
|
+
? 'printf "oom 3\\noom_kill 3\\n" > "$START_COMMAND_CGROUP_ROOT/docker/238abc/memory.events"'
|
|
23
|
+
: ':',
|
|
24
|
+
buildCgroupSamplerStopSnippet(),
|
|
25
|
+
'printf "sample=%s\\n" "$__start_command_cgroup"',
|
|
26
|
+
buildCgroupMemoryLogSnippet('"$TMPDIR/memory.log"'),
|
|
27
|
+
'cat "$TMPDIR/memory.log" "$TMPDIR/calls"',
|
|
28
|
+
'sed "s/^/sleep=/" "$TMPDIR/sleeps"',
|
|
29
|
+
// Stop must remove every temporary sample file.
|
|
30
|
+
'for file in "$TMPDIR"/start-command-cgroup*; do [ ! -e "$file" ] || exit 1; done',
|
|
31
|
+
].join('; ')
|
|
32
|
+
);
|
|
33
|
+
const env = { ...process.env, START_SAMPLER_CLOCK_STEP: clockStep };
|
|
34
|
+
delete env.START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS;
|
|
35
|
+
if (interval !== undefined) {
|
|
36
|
+
env.START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS = interval;
|
|
37
|
+
}
|
|
38
|
+
const result = spawnSync(
|
|
39
|
+
'/bin/sh',
|
|
40
|
+
[
|
|
41
|
+
path.resolve(
|
|
42
|
+
__dirname,
|
|
43
|
+
'../../experiments/issue-238-cgroup-polling.sh'
|
|
44
|
+
),
|
|
45
|
+
mode,
|
|
46
|
+
script,
|
|
47
|
+
],
|
|
48
|
+
{ encoding: 'utf8', env }
|
|
49
|
+
);
|
|
50
|
+
expect(result.status).toBe(0);
|
|
51
|
+
return result.stdout;
|
|
52
|
+
} finally {
|
|
53
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const count = (output, text) =>
|
|
58
|
+
output.split('\n').filter((line) => line.includes(text)).length;
|
|
59
|
+
const posix = process.platform === 'win32' ? describe.skip : describe;
|
|
60
|
+
|
|
61
|
+
posix('issue #238: bounded Docker cgroup sampling', () => {
|
|
62
|
+
it('defaults to five seconds and inspects local liveness only after removal', () => {
|
|
63
|
+
const output = sample('local');
|
|
64
|
+
expect(count(output, 'sleep=5')).toBe(3);
|
|
65
|
+
expect(count(output, '{{.State.Running}}')).toBe(1);
|
|
66
|
+
expect(count(output, '{{.HostConfig.CgroupnsMode}}')).toBe(1);
|
|
67
|
+
// A readable local cgroup never starts a remote exec, even after removal
|
|
68
|
+
// (issue #242): the liveness probe ends the loop instead.
|
|
69
|
+
expect(count(output, "task '238' sh -c")).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('honors an override in the watcher environment and caches remote namespace', () => {
|
|
73
|
+
const output = sample('remote', '2');
|
|
74
|
+
expect(count(output, 'sleep=2')).toBe(3);
|
|
75
|
+
expect(count(output, '{{.HostConfig.CgroupnsMode}}')).toBe(1);
|
|
76
|
+
// One long-lived exec streams every round; liveness is probed once, after
|
|
77
|
+
// the stream ends (issue #242).
|
|
78
|
+
expect(count(output, '{{.State.Running}}')).toBe(1);
|
|
79
|
+
expect(count(output, "task '238' sh -c")).toBe(1);
|
|
80
|
+
expect(output).toContain(
|
|
81
|
+
'memory.max=67108864 memory.peak=33554432 oom=1 oom_kill=3'
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects zero, negative, fractional, malformed and unsafe intervals', () => {
|
|
86
|
+
for (const interval of [
|
|
87
|
+
'',
|
|
88
|
+
'0',
|
|
89
|
+
'000',
|
|
90
|
+
'-1',
|
|
91
|
+
'0.5',
|
|
92
|
+
'nope',
|
|
93
|
+
'1; exit 7',
|
|
94
|
+
'99999999999999999999999999',
|
|
95
|
+
]) {
|
|
96
|
+
expect(count(sample('remote', interval), 'sleep=5')).toBe(3);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('keeps unavailable diagnostics and the last sample across remote outages', () => {
|
|
101
|
+
for (const mode of ['shared', 'no-shell', 'outage']) {
|
|
102
|
+
const output = sample(mode);
|
|
103
|
+
expect(count(output, '{{.HostConfig.CgroupnsMode}}')).toBe(1);
|
|
104
|
+
// A shared namespace or a missing shell ends sampling at once; an outage
|
|
105
|
+
// falls back to per-round execs, each followed by a liveness probe.
|
|
106
|
+
expect(count(output, '{{.State.Running}}')).toBe(
|
|
107
|
+
mode === 'outage' ? 4 : 0
|
|
108
|
+
);
|
|
109
|
+
expect(count(output, "task '238' sh -c")).toBe(
|
|
110
|
+
{ shared: 0, 'no-shell': 1, outage: 4 }[mode]
|
|
111
|
+
);
|
|
112
|
+
if (mode === 'outage') {
|
|
113
|
+
expect(output).toContain('sample=67108864 33554432 1 3 0 100');
|
|
114
|
+
} else {
|
|
115
|
+
expect(output).toContain('Memory: unavailable');
|
|
116
|
+
expect(output).toContain(
|
|
117
|
+
mode === 'shared' ? 'namespace=host' : 'sh: not found'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('preserves a final read without treating a five-second counter gap as recent OOM', () => {
|
|
124
|
+
expect(sample('final')).toContain('sample=67108864 33554432 3 3 0 115');
|
|
125
|
+
expect(sample('final', '1', '1')).toContain(
|
|
126
|
+
'sample=67108864 33554432 3 3 103 103'
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #241: a daemon restart was attributed only from
|
|
3
|
+
* `journalctl -u docker.service`. With a mounted docker.sock
|
|
4
|
+
* (Docker-outside-of-Docker) there is no host journal, so a task that the
|
|
5
|
+
* restart force-killed ended with `Exit evidence: unavailable`.
|
|
6
|
+
*
|
|
7
|
+
* `docker events` cannot help (its buffer does not survive the restart), but
|
|
8
|
+
* `State.FinishedAt` / `State.StartedAt` of the other containers do.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { describe, it, expect, afterEach } = require('bun:test');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const evidence = require('../src/lib/exit-evidence');
|
|
17
|
+
const { resolveExitReason } = require('../src/lib/exit-reason');
|
|
18
|
+
|
|
19
|
+
const TASK = 'a'.repeat(64);
|
|
20
|
+
const PEER = 'b'.repeat(64);
|
|
21
|
+
const FINISHED = '2026-10-21T10:05:00.123456789Z';
|
|
22
|
+
const state = (id, finished, started, exit, oom = 'false') =>
|
|
23
|
+
`${id} ${finished} ${started} ${exit} ${oom}`;
|
|
24
|
+
// Two containers that finished in the same second, the shape of a restart.
|
|
25
|
+
const TOGETHER = [
|
|
26
|
+
state(TASK, FINISHED, '2026-10-21T10:00:00Z', 137),
|
|
27
|
+
state(PEER, '2026-10-21T10:05:00.9Z', '2026-10-21T09:00:00Z', 137),
|
|
28
|
+
].join('\n');
|
|
29
|
+
|
|
30
|
+
/** Runner answering like a daemon reached through a mounted socket. */
|
|
31
|
+
function socketOnlyRunner(states, calls = []) {
|
|
32
|
+
return (command, args) => {
|
|
33
|
+
calls.push([command, ...args]);
|
|
34
|
+
if (command === 'journalctl') {
|
|
35
|
+
// No host journal inside the container.
|
|
36
|
+
return { success: false, stdout: '' };
|
|
37
|
+
}
|
|
38
|
+
if (args[0] === 'inspect' && args[2] === '{{.Id}}') {
|
|
39
|
+
return { success: true, stdout: `${TASK}\n` };
|
|
40
|
+
}
|
|
41
|
+
if (args[0] === 'events') {
|
|
42
|
+
return { success: true, stdout: '' };
|
|
43
|
+
}
|
|
44
|
+
if (args[0] === 'ps') {
|
|
45
|
+
return { success: true, stdout: `${TASK}\n${PEER}\n` };
|
|
46
|
+
}
|
|
47
|
+
if (args[0] === 'inspect') {
|
|
48
|
+
return { success: true, stdout: states };
|
|
49
|
+
}
|
|
50
|
+
return { success: false, stdout: '' };
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const collect = (states, extra = {}, calls) =>
|
|
55
|
+
evidence.collectExitEvidence(
|
|
56
|
+
{
|
|
57
|
+
containerName: 'task',
|
|
58
|
+
sample: '',
|
|
59
|
+
finishedAt: FINISHED,
|
|
60
|
+
exitCode: 137,
|
|
61
|
+
oomKilled: false,
|
|
62
|
+
...extra,
|
|
63
|
+
},
|
|
64
|
+
socketOnlyRunner(states, calls)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
describe('issue #241: daemon-restart evidence without the host journal', () => {
|
|
68
|
+
it('attributes two containers that finished in the same second to the daemon', () => {
|
|
69
|
+
const calls = [];
|
|
70
|
+
const line = collect(TOGETHER, {}, calls);
|
|
71
|
+
expect(line).toBe(evidence.DAEMON_RESTART_API);
|
|
72
|
+
expect(calls).toContainEqual(['docker', 'ps', '-a', '-q', '--no-trunc']);
|
|
73
|
+
// The task itself is never its own witness.
|
|
74
|
+
const inspect = calls.find(
|
|
75
|
+
(call) => call[1] === 'inspect' && call[3] !== '{{.Id}}'
|
|
76
|
+
);
|
|
77
|
+
expect(inspect.slice(4)).toEqual([PEER]);
|
|
78
|
+
expect(
|
|
79
|
+
resolveExitReason({ exitCode: 137, oomKilled: false, logTail: line })
|
|
80
|
+
).toBe('killed (docker daemon restart)');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('reports which source supported the attribution', () => {
|
|
84
|
+
expect(evidence.fromLog(evidence.DAEMON_RESTART_API)).toEqual({
|
|
85
|
+
mainOom: false,
|
|
86
|
+
daemonRestart: true,
|
|
87
|
+
daemonRestartSource: 'api',
|
|
88
|
+
});
|
|
89
|
+
expect(evidence.fromLog(evidence.DAEMON_RESTART)).toEqual({
|
|
90
|
+
mainOom: false,
|
|
91
|
+
daemonRestart: true,
|
|
92
|
+
daemonRestartSource: 'journal',
|
|
93
|
+
});
|
|
94
|
+
expect(evidence.fromLog(evidence.MAIN_OOM)).toEqual({
|
|
95
|
+
mainOom: true,
|
|
96
|
+
daemonRestart: false,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('counts a container restarted after it finished, whose ExitCode moby reset to 0', () => {
|
|
101
|
+
const restarted = state(
|
|
102
|
+
PEER,
|
|
103
|
+
'2026-10-21T10:04:52Z',
|
|
104
|
+
'2026-10-21T10:05:20Z',
|
|
105
|
+
0
|
|
106
|
+
);
|
|
107
|
+
expect(evidence.daemonRestartApiEvidence(restarted, TASK, FINISHED)).toBe(
|
|
108
|
+
true
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
for (const [name, peer] of [
|
|
113
|
+
[
|
|
114
|
+
'an OOM-killed peer',
|
|
115
|
+
state(PEER, FINISHED, '2026-10-21T09:00:00Z', 137, 'true'),
|
|
116
|
+
],
|
|
117
|
+
[
|
|
118
|
+
'a peer that finished 30 s apart',
|
|
119
|
+
state(PEER, '2026-10-21T10:05:30.2Z', '2026-10-21T09:00:00Z', 137),
|
|
120
|
+
],
|
|
121
|
+
[
|
|
122
|
+
'a peer that exited on its own',
|
|
123
|
+
state(PEER, FINISHED, '2026-10-21T09:00:00Z', 0),
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
'a peer that never ran',
|
|
127
|
+
state(PEER, '0001-01-01T00:00:00Z', '0001-01-01T00:00:00Z', 0),
|
|
128
|
+
],
|
|
129
|
+
[
|
|
130
|
+
'only the task itself',
|
|
131
|
+
state(TASK, FINISHED, '2026-10-21T09:00:00Z', 137),
|
|
132
|
+
],
|
|
133
|
+
['malformed output', 'Error: No such object: gone'],
|
|
134
|
+
]) {
|
|
135
|
+
it(`does not attribute ${name}`, () => {
|
|
136
|
+
expect(evidence.daemonRestartApiEvidence(peer, TASK, FINISHED)).toBe(
|
|
137
|
+
false
|
|
138
|
+
);
|
|
139
|
+
expect(collect(peer)).toBe(
|
|
140
|
+
'Exit evidence: unavailable (no attributed exit-time OOM or daemon restart evidence)'
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
it('also works for a remote daemon, where no journal is consulted', () => {
|
|
146
|
+
const original = process.env.DOCKER_HOST;
|
|
147
|
+
process.env.DOCKER_HOST = 'tcp://docker.example:2376';
|
|
148
|
+
try {
|
|
149
|
+
const calls = [];
|
|
150
|
+
expect(collect(TOGETHER, {}, calls)).toBe(evidence.DAEMON_RESTART_API);
|
|
151
|
+
expect(calls.some(([command]) => command === 'journalctl')).toBe(false);
|
|
152
|
+
} finally {
|
|
153
|
+
if (original === undefined) {
|
|
154
|
+
delete process.env.DOCKER_HOST;
|
|
155
|
+
} else {
|
|
156
|
+
process.env.DOCKER_HOST = original;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('keeps journal and attempt-scoped OOM evidence ahead of the API check', () => {
|
|
162
|
+
const calls = [];
|
|
163
|
+
const line = collect(
|
|
164
|
+
TOGETHER,
|
|
165
|
+
{ oomKilled: true, sample: '64 63 3 2 1792577100 1792577100' },
|
|
166
|
+
calls
|
|
167
|
+
);
|
|
168
|
+
expect(line).toBe(evidence.MAIN_OOM);
|
|
169
|
+
expect(calls.some((call) => call[1] === 'ps')).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const tempDirs = [];
|
|
174
|
+
afterEach(() => {
|
|
175
|
+
while (tempDirs.length > 0) {
|
|
176
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('issue #241: --status collects evidence the lost watcher never wrote', () => {
|
|
181
|
+
it('reports a daemon restart for a DooD task whose watcher died with the daemon', () => {
|
|
182
|
+
if (process.platform === 'win32') {
|
|
183
|
+
console.log(' Skipping: the docker stub needs a POSIX /bin/sh');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evidence-241-'));
|
|
187
|
+
tempDirs.push(dir);
|
|
188
|
+
const docker = path.join(dir, 'docker');
|
|
189
|
+
fs.writeFileSync(
|
|
190
|
+
docker,
|
|
191
|
+
[
|
|
192
|
+
'#!/bin/sh',
|
|
193
|
+
'case "$1 $3" in',
|
|
194
|
+
` "inspect {{.Id}}") echo ${TASK} ;;`,
|
|
195
|
+
` "inspect {{.Id}} "*) echo '${state(PEER, '2026-10-21T10:05:00.9Z', '2026-10-21T10:05:30Z', 0)}' ;;`,
|
|
196
|
+
` "inspect "*) echo 'false 137 false 2026-10-21T10:00:00Z ${FINISHED}' ;;`,
|
|
197
|
+
` "ps "*) printf '%s\\n' ${TASK} ${PEER} ;;`,
|
|
198
|
+
' "events "*) ;;',
|
|
199
|
+
' *) exit 1 ;;',
|
|
200
|
+
'esac',
|
|
201
|
+
'',
|
|
202
|
+
].join('\n')
|
|
203
|
+
);
|
|
204
|
+
fs.chmodSync(docker, 0o755);
|
|
205
|
+
// No host journal, as inside a container with a mounted docker.sock.
|
|
206
|
+
fs.writeFileSync(path.join(dir, 'journalctl'), '#!/bin/sh\nexit 1\n');
|
|
207
|
+
fs.chmodSync(path.join(dir, 'journalctl'), 0o755);
|
|
208
|
+
const saved = {
|
|
209
|
+
START_DOCKER_BIN: process.env.START_DOCKER_BIN,
|
|
210
|
+
PATH: process.env.PATH,
|
|
211
|
+
DOCKER_HOST: process.env.DOCKER_HOST,
|
|
212
|
+
};
|
|
213
|
+
process.env.START_DOCKER_BIN = docker;
|
|
214
|
+
process.env.PATH = `${dir}${path.delimiter}${process.env.PATH}`;
|
|
215
|
+
delete process.env.DOCKER_HOST;
|
|
216
|
+
try {
|
|
217
|
+
const {
|
|
218
|
+
ExecutionStore,
|
|
219
|
+
ExecutionRecord,
|
|
220
|
+
} = require('../src/lib/execution-store');
|
|
221
|
+
const { queryStatus } = require('../src/lib/status-formatter');
|
|
222
|
+
const store = new ExecutionStore({ appFolder: dir, useLinks: false });
|
|
223
|
+
const record = new ExecutionRecord({
|
|
224
|
+
command: 'sleep 600',
|
|
225
|
+
options: {
|
|
226
|
+
isolated: 'docker',
|
|
227
|
+
isolationMode: 'detached',
|
|
228
|
+
sessionName: 'task-241',
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
record.logPath = path.join(dir, 'task.log');
|
|
232
|
+
// The watcher died before it could log anything after the output.
|
|
233
|
+
fs.writeFileSync(record.logPath, 'working\n');
|
|
234
|
+
store.save(record);
|
|
235
|
+
const status = JSON.parse(queryStatus(store, record.uuid, 'json').output);
|
|
236
|
+
expect(status.status).toBe('executed');
|
|
237
|
+
expect(status.exitCode).toBe(137);
|
|
238
|
+
expect(status.exitReason).toBe('killed (docker daemon restart)');
|
|
239
|
+
expect(status.options.exitEvidence).toEqual({
|
|
240
|
+
mainOom: false,
|
|
241
|
+
daemonRestart: true,
|
|
242
|
+
daemonRestartSource: 'api',
|
|
243
|
+
});
|
|
244
|
+
} finally {
|
|
245
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
246
|
+
if (value === undefined) {
|
|
247
|
+
delete process.env[key];
|
|
248
|
+
} else {
|
|
249
|
+
process.env[key] = value;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
});
|