start-command 0.29.1 → 0.29.2
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/lib/docker-utils.js +133 -6
- package/src/lib/isolation.js +3 -1
- package/test/regression-138.js +199 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.29.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8518034: Record the docker image-preparation phase in the session log (issue #138). When a `--isolated docker` run needs to `docker pull` an image, the pull output is now teed into the session-log file (`/tmp/start-command/logs/isolation/docker/<uuid>.log`) in real time and bracketed with `Preparing image <name>…` / `Image ready (<duration>)` markers (or `Image preparation failed` on error). Previously the minutes spent pulling a (potentially multi-GB) image left no trace in the log, so operators tailing `$ --upload-log <uuid>` during startup saw only the header. The single session-log file is now a gap-free record of the run, including the longest, most failure-prone phase.
|
|
8
|
+
|
|
3
9
|
## 0.29.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
package/src/lib/docker-utils.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
10
12
|
const { execSync, spawnSync } = require('child_process');
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -102,31 +104,141 @@ function dockerImageExists(image) {
|
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Run `docker pull <image>` while teeing its output to the session log file.
|
|
109
|
+
*
|
|
110
|
+
* When a logPath is given (and tee is available, i.e. non-Windows), the pull
|
|
111
|
+
* output is streamed to BOTH the console and the log file in real time so the
|
|
112
|
+
* image-preparation phase is captured in the single session log (issue #138).
|
|
113
|
+
* docker's own exit code is recovered via a sentinel status file because the
|
|
114
|
+
* exit status of a `cmd | tee` pipeline reflects tee, not docker.
|
|
115
|
+
*
|
|
116
|
+
* Without a logPath, it falls back to the previous behavior: inherited stdio
|
|
117
|
+
* for real-time console output, with no log capture. On Windows (no portable
|
|
118
|
+
* shell `tee`) but with a logPath, the pull output is captured, echoed to the
|
|
119
|
+
* console, and appended to the log after the pull so the session log still
|
|
120
|
+
* records the image-preparation phase (issue #138) — just not streamed live.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} image - Docker image to pull
|
|
123
|
+
* @param {string|null} logPath - Session log file to append pull output to
|
|
124
|
+
* @returns {{status: number, error?: Error}} Spawn result (status = docker exit code)
|
|
125
|
+
*/
|
|
126
|
+
function runDockerPull(image, logPath) {
|
|
127
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
128
|
+
|
|
129
|
+
// Without a log target, keep the original inherited-stdio behavior for
|
|
130
|
+
// fancy real-time console output.
|
|
131
|
+
if (!logPath) {
|
|
132
|
+
return spawnSync('docker', ['pull', image], {
|
|
133
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Windows has no portable shell `tee`, so capture the pull output, echo it to
|
|
138
|
+
// the console, and append it to the log so the image-preparation phase is
|
|
139
|
+
// still recorded in the session log (issue #138), just not streamed live.
|
|
140
|
+
if (process.platform === 'win32') {
|
|
141
|
+
const { appendLogFile } = require('./isolation-log-utils');
|
|
142
|
+
const result = spawnSync('docker', ['pull', image], {
|
|
143
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
});
|
|
146
|
+
const combined = `${result.stdout || ''}${result.stderr || ''}`;
|
|
147
|
+
if (combined) {
|
|
148
|
+
process.stdout.write(combined);
|
|
149
|
+
try {
|
|
150
|
+
appendLogFile(
|
|
151
|
+
logPath,
|
|
152
|
+
combined.endsWith('\n') ? combined : `${combined}\n`
|
|
153
|
+
);
|
|
154
|
+
} catch {
|
|
155
|
+
// best-effort: log capture must never break the pull
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Tee docker pull output to both the console and the log file. docker writes
|
|
162
|
+
// to a pipe here, so it emits plain progress lines (ideal for a log). The
|
|
163
|
+
// sentinel file captures docker's real exit code (the pipeline's own exit
|
|
164
|
+
// status would be tee's).
|
|
165
|
+
const statusFile = path.join(
|
|
166
|
+
os.tmpdir(),
|
|
167
|
+
`start-docker-pull-${process.pid}-${Date.now()}.status`
|
|
168
|
+
);
|
|
169
|
+
try {
|
|
170
|
+
const pipeline =
|
|
171
|
+
`{ docker pull ${shellQuote(image)} 2>&1; echo $? > ${shellQuote(statusFile)}; } ` +
|
|
172
|
+
`| tee -a ${shellQuote(logPath)}`;
|
|
173
|
+
const result = spawnSync('sh', ['-c', pipeline], {
|
|
174
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
175
|
+
});
|
|
176
|
+
if (result.error) {
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
let status = result.status;
|
|
180
|
+
try {
|
|
181
|
+
const recorded = fs.readFileSync(statusFile, 'utf8').trim();
|
|
182
|
+
if (recorded !== '') {
|
|
183
|
+
status = parseInt(recorded, 10);
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// Sentinel missing (e.g. sh/tee failed before docker ran); keep pipeline status.
|
|
187
|
+
}
|
|
188
|
+
return { ...result, status };
|
|
189
|
+
} finally {
|
|
190
|
+
try {
|
|
191
|
+
fs.unlinkSync(statusFile);
|
|
192
|
+
} catch {
|
|
193
|
+
// best-effort cleanup
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
105
198
|
/**
|
|
106
199
|
* Pull a Docker image with output streaming
|
|
107
|
-
* Displays the pull operation as a virtual command in the timeline
|
|
200
|
+
* Displays the pull operation as a virtual command in the timeline.
|
|
201
|
+
*
|
|
202
|
+
* When a logPath is provided, the image-preparation phase (the `docker pull`)
|
|
203
|
+
* is also recorded in the session log so the single log file is a gap-free
|
|
204
|
+
* record of everything that ran (issue #138): a `Preparing image …` marker with
|
|
205
|
+
* a timestamp is written before the pull, the pull output is teed into the log,
|
|
206
|
+
* and an `Image ready (<duration>)` marker is written afterwards.
|
|
207
|
+
*
|
|
108
208
|
* @param {string} image - Docker image to pull
|
|
209
|
+
* @param {string|null} logPath - Optional session log file to append output to
|
|
109
210
|
* @returns {{success: boolean, output: string}} Pull result
|
|
110
211
|
*/
|
|
111
|
-
function dockerPullImage(image) {
|
|
212
|
+
function dockerPullImage(image, logPath = null) {
|
|
112
213
|
const {
|
|
113
214
|
createVirtualCommandBlock,
|
|
114
215
|
createVirtualCommandResult,
|
|
115
216
|
createTimelineSeparator,
|
|
116
217
|
} = require('./output-blocks');
|
|
218
|
+
const { appendLogFile, getTimestamp } = require('./isolation-log-utils');
|
|
117
219
|
|
|
118
220
|
// Print the virtual command line followed by empty line for visual separation
|
|
119
221
|
console.log(createVirtualCommandBlock(`docker pull ${image}`));
|
|
120
222
|
console.log();
|
|
121
223
|
|
|
224
|
+
// Record the start of the image-preparation phase in the session log so
|
|
225
|
+
// operators tailing the log see progress instead of a header-only file.
|
|
226
|
+
const prepStartMs = Date.now();
|
|
227
|
+
if (logPath) {
|
|
228
|
+
appendLogFile(
|
|
229
|
+
logPath,
|
|
230
|
+
`$ docker pull ${image}\nPreparing image ${image}… (${getTimestamp()})\n`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
122
234
|
let output = '';
|
|
123
235
|
let success;
|
|
124
236
|
|
|
125
237
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
238
|
+
const result = runDockerPull(image, logPath);
|
|
239
|
+
if (result.error) {
|
|
240
|
+
throw result.error;
|
|
241
|
+
}
|
|
130
242
|
|
|
131
243
|
success = result.status === 0;
|
|
132
244
|
|
|
@@ -140,6 +252,21 @@ function dockerPullImage(image) {
|
|
|
140
252
|
console.error(`Failed to run docker pull: ${err.message}`);
|
|
141
253
|
output = err.message;
|
|
142
254
|
success = false;
|
|
255
|
+
if (logPath) {
|
|
256
|
+
appendLogFile(logPath, `Failed to run docker pull: ${err.message}\n`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Record the end of the image-preparation phase with elapsed duration so the
|
|
261
|
+
// prep time is visible even when full progress is unavailable (issue #138).
|
|
262
|
+
if (logPath) {
|
|
263
|
+
const durationSec = ((Date.now() - prepStartMs) / 1000).toFixed(1);
|
|
264
|
+
appendLogFile(
|
|
265
|
+
logPath,
|
|
266
|
+
success
|
|
267
|
+
? `Image ready (${durationSec}s)\n`
|
|
268
|
+
: `Image preparation failed (${durationSec}s)\n`
|
|
269
|
+
);
|
|
143
270
|
}
|
|
144
271
|
|
|
145
272
|
// Print empty line before result marker for visual separation (issue #73)
|
package/src/lib/isolation.js
CHANGED
|
@@ -588,7 +588,9 @@ function runInDocker(command, options = {}) {
|
|
|
588
588
|
|
|
589
589
|
const containerName = options.session || generateSessionName('docker');
|
|
590
590
|
if (!dockerImageExists(options.image)) {
|
|
591
|
-
|
|
591
|
+
// Pass logPath so the image-preparation phase (docker pull) is recorded in
|
|
592
|
+
// the session log, keeping it a gap-free record of the run (issue #138).
|
|
593
|
+
const pullResult = dockerPullImage(options.image, options.logPath);
|
|
592
594
|
if (!pullResult.success) {
|
|
593
595
|
return Promise.resolve({
|
|
594
596
|
success: false,
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Regression tests for issue #138:
|
|
4
|
+
* "Detached docker session log omits the image-preparation phase
|
|
5
|
+
* (docker pull / dind boot) — $ does not preserve the full log in one file"
|
|
6
|
+
*
|
|
7
|
+
* When a command is launched with `--isolated docker`, the image-preparation
|
|
8
|
+
* phase (the `docker pull`) used to be printed to the console only and never
|
|
9
|
+
* written to the session log file. An operator tailing the session log (e.g.
|
|
10
|
+
* via `$ --upload-log <uuid>`) during a multi-GB pull would see only the
|
|
11
|
+
* header — the minutes spent pulling left no trace in the log.
|
|
12
|
+
*
|
|
13
|
+
* The fix tees the pull output into the session log and brackets it with
|
|
14
|
+
* `Preparing image …` / `Image ready (<duration>)` markers so the single
|
|
15
|
+
* session-log file is a gap-free record of everything that ran.
|
|
16
|
+
*
|
|
17
|
+
* Reference: https://github.com/link-foundation/start/issues/138
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { describe, it, beforeEach, afterEach } = require('node:test');
|
|
21
|
+
const assert = require('assert');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
dockerPullImage,
|
|
28
|
+
dockerImageExists,
|
|
29
|
+
isDockerAvailable,
|
|
30
|
+
} = require('../src/lib/docker-utils');
|
|
31
|
+
|
|
32
|
+
function makeTempLog() {
|
|
33
|
+
const logPath = path.join(
|
|
34
|
+
os.tmpdir(),
|
|
35
|
+
`start-138-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.log`
|
|
36
|
+
);
|
|
37
|
+
fs.writeFileSync(logPath, '=== Start Command Log ===\n');
|
|
38
|
+
return logPath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('docker pull is recorded in the session log (issue #138)', () => {
|
|
42
|
+
// Silence the virtual-command console output during these tests.
|
|
43
|
+
let originalLog;
|
|
44
|
+
let originalError;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
originalLog = console.log;
|
|
47
|
+
originalError = console.error;
|
|
48
|
+
console.log = () => {};
|
|
49
|
+
console.error = () => {};
|
|
50
|
+
});
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
console.log = originalLog;
|
|
53
|
+
console.error = originalError;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('writes prep markers and the docker error into the log on a failed pull', () => {
|
|
57
|
+
if (!isDockerAvailable()) {
|
|
58
|
+
console.log = originalLog;
|
|
59
|
+
console.log(' Skipping: docker daemon not available');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const logPath = makeTempLog();
|
|
64
|
+
try {
|
|
65
|
+
// An invalid reference format fails fast without any network access.
|
|
66
|
+
const result = dockerPullImage('invalid..badname', logPath);
|
|
67
|
+
const contents = fs.readFileSync(logPath, 'utf8');
|
|
68
|
+
|
|
69
|
+
assert.strictEqual(
|
|
70
|
+
result.success,
|
|
71
|
+
false,
|
|
72
|
+
'pull of invalid ref must fail'
|
|
73
|
+
);
|
|
74
|
+
assert.ok(
|
|
75
|
+
contents.includes('Preparing image invalid..badname'),
|
|
76
|
+
'log must contain the "Preparing image …" start marker'
|
|
77
|
+
);
|
|
78
|
+
assert.ok(
|
|
79
|
+
contents.includes('Image preparation failed'),
|
|
80
|
+
'log must contain the failure marker with elapsed duration'
|
|
81
|
+
);
|
|
82
|
+
// The docker error itself (teed pull output) must be in the log, not just
|
|
83
|
+
// on the console — this is the core of issue #138.
|
|
84
|
+
assert.ok(
|
|
85
|
+
contents.includes('invalid reference format'),
|
|
86
|
+
'log must capture the teed docker pull error output'
|
|
87
|
+
);
|
|
88
|
+
} finally {
|
|
89
|
+
fs.rmSync(logPath, { force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('tees real pull output and an "Image ready" marker into the log', () => {
|
|
94
|
+
if (!isDockerAvailable()) {
|
|
95
|
+
console.log = originalLog;
|
|
96
|
+
console.log(' Skipping: docker daemon not available');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (process.platform === 'win32') {
|
|
100
|
+
// Windows Docker runners pull Linux images slowly/unreliably and the
|
|
101
|
+
// capture path is exercised by the failed-pull test above; skip to avoid
|
|
102
|
+
// network-dependent timeouts. The real-pull streaming path is Unix-only.
|
|
103
|
+
console.log = originalLog;
|
|
104
|
+
console.log(' Skipping: real pull is unreliable on Windows Docker');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Use a tiny image and force a real pull by removing it first.
|
|
109
|
+
const image = 'hello-world:latest';
|
|
110
|
+
try {
|
|
111
|
+
require('child_process').execSync(`docker rmi ${image}`, {
|
|
112
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
// image not present locally — that's fine, the pull below still runs
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const logPath = makeTempLog();
|
|
119
|
+
try {
|
|
120
|
+
const result = dockerPullImage(image, logPath);
|
|
121
|
+
if (!result.success) {
|
|
122
|
+
// No network in this environment — cannot exercise the success path.
|
|
123
|
+
console.log = originalLog;
|
|
124
|
+
console.log(' Skipping: docker pull failed (no registry access?)');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const contents = fs.readFileSync(logPath, 'utf8');
|
|
129
|
+
assert.ok(
|
|
130
|
+
contents.includes(`Preparing image ${image}`),
|
|
131
|
+
'log must contain the "Preparing image …" start marker'
|
|
132
|
+
);
|
|
133
|
+
assert.ok(
|
|
134
|
+
/Image ready \(\d+\.\d+s\)/.test(contents),
|
|
135
|
+
'log must contain "Image ready (<duration>)" marker'
|
|
136
|
+
);
|
|
137
|
+
assert.ok(
|
|
138
|
+
contents.includes('Pulling from') ||
|
|
139
|
+
contents.includes('Status:') ||
|
|
140
|
+
contents.includes('Pull complete'),
|
|
141
|
+
'log must capture the teed docker pull progress output'
|
|
142
|
+
);
|
|
143
|
+
assert.ok(
|
|
144
|
+
dockerImageExists(image),
|
|
145
|
+
'image should exist locally after a successful pull'
|
|
146
|
+
);
|
|
147
|
+
} finally {
|
|
148
|
+
fs.rmSync(logPath, { force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not write prep markers when no logPath is given (backward compat)', () => {
|
|
153
|
+
if (!isDockerAvailable()) {
|
|
154
|
+
console.log = originalLog;
|
|
155
|
+
console.log(' Skipping: docker daemon not available');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Without a logPath, dockerPullImage must still return the {success, output}
|
|
160
|
+
// shape and must not throw. We only assert the contract here.
|
|
161
|
+
const result = dockerPullImage('invalid..badname');
|
|
162
|
+
assert.strictEqual(result.success, false);
|
|
163
|
+
assert.strictEqual(typeof result.output, 'string');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('runInDocker threads logPath into dockerPullImage (issue #138)', () => {
|
|
168
|
+
it('source passes options.logPath to dockerPullImage', () => {
|
|
169
|
+
const isolationSrc = fs.readFileSync(
|
|
170
|
+
path.join(__dirname, '../src/lib/isolation.js'),
|
|
171
|
+
'utf8'
|
|
172
|
+
);
|
|
173
|
+
assert.ok(
|
|
174
|
+
/dockerPullImage\(options\.image,\s*options\.logPath\)/.test(
|
|
175
|
+
isolationSrc
|
|
176
|
+
),
|
|
177
|
+
'runInDocker must pass options.logPath to dockerPullImage'
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('dockerPullImage tees output and writes prep markers in source', () => {
|
|
182
|
+
const dockerUtilsSrc = fs.readFileSync(
|
|
183
|
+
path.join(__dirname, '../src/lib/docker-utils.js'),
|
|
184
|
+
'utf8'
|
|
185
|
+
);
|
|
186
|
+
assert.ok(
|
|
187
|
+
dockerUtilsSrc.includes('Preparing image'),
|
|
188
|
+
'docker-utils must write a "Preparing image …" marker'
|
|
189
|
+
);
|
|
190
|
+
assert.ok(
|
|
191
|
+
dockerUtilsSrc.includes('Image ready'),
|
|
192
|
+
'docker-utils must write an "Image ready (<duration>)" marker'
|
|
193
|
+
);
|
|
194
|
+
assert.ok(
|
|
195
|
+
dockerUtilsSrc.includes('tee -a'),
|
|
196
|
+
'docker-utils must tee docker pull output into the log file'
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
});
|