start-command 0.29.1 → 0.30.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 +12 -0
- package/package.json +1 -1
- package/src/bin/cli.js +7 -0
- package/src/lib/args-parser.js +57 -13
- package/src/lib/command-builder.js +13 -2
- package/src/lib/docker-cleanup.js +145 -0
- package/src/lib/docker-utils.js +133 -6
- package/src/lib/isolation.js +52 -29
- package/src/lib/usage.js +4 -1
- package/test/args-parser-docker-cleanup.js +143 -0
- package/test/args-parser.js +0 -69
- package/test/command-builder-docker-cleanup.js +35 -0
- package/test/docker-autoremove.js +204 -29
- package/test/regression-138.js +199 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.30.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3d7fe09: Clean up Docker isolation containers by default after completion, preserve host log files, and add explicit `--keep-container`, `--always-cleanup-container`, and `--keep-container-on-fail` cleanup policy flags.
|
|
8
|
+
|
|
9
|
+
## 0.29.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 0.29.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/bin/cli.js
CHANGED
|
@@ -503,6 +503,10 @@ async function runWithIsolation(
|
|
|
503
503
|
endpoint: options.endpoint,
|
|
504
504
|
user: options.user,
|
|
505
505
|
keepAlive: options.keepAlive,
|
|
506
|
+
autoRemoveDockerContainer: options.autoRemoveDockerContainer,
|
|
507
|
+
alwaysCleanupContainer: options.alwaysCleanupContainer,
|
|
508
|
+
keepContainer: options.keepContainer,
|
|
509
|
+
keepContainerOnFail: options.keepContainerOnFail,
|
|
506
510
|
useCommandStream,
|
|
507
511
|
},
|
|
508
512
|
});
|
|
@@ -574,6 +578,9 @@ async function runWithIsolation(
|
|
|
574
578
|
user: createdUser,
|
|
575
579
|
keepAlive: options.keepAlive,
|
|
576
580
|
autoRemoveDockerContainer: options.autoRemoveDockerContainer,
|
|
581
|
+
alwaysCleanupContainer: options.alwaysCleanupContainer,
|
|
582
|
+
keepContainer: options.keepContainer,
|
|
583
|
+
keepContainerOnFail: options.keepContainerOnFail,
|
|
577
584
|
volumes: options.volumes,
|
|
578
585
|
mounts: options.mounts,
|
|
579
586
|
env: options.env,
|
package/src/lib/args-parser.js
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
|
|
20
20
|
* --keep-user Keep isolated user after command completes (don't delete)
|
|
21
21
|
* --keep-alive, -k Keep isolation environment alive after command exits
|
|
22
|
-
* --auto-remove-docker-container
|
|
22
|
+
* --auto-remove-docker-container Always remove docker container after exit (compatibility alias)
|
|
23
|
+
* --always-cleanup-container Always remove docker container after exit (default)
|
|
24
|
+
* --keep-container Keep docker container filesystem after exit
|
|
25
|
+
* --keep-container-on-fail Remove successful docker containers, keep failed ones
|
|
23
26
|
* --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
|
|
24
27
|
* --use-command-stream Use command-stream library for command execution (experimental)
|
|
25
28
|
* --verbose Enable verbose/debug output (sets START_VERBOSE=1)
|
|
@@ -181,7 +184,10 @@ function parseArgs(args) {
|
|
|
181
184
|
userName: null, // Optional custom username for isolated user
|
|
182
185
|
keepUser: false, // Keep isolated user after command completes (don't delete)
|
|
183
186
|
keepAlive: false, // Keep environment alive after command exits
|
|
184
|
-
autoRemoveDockerContainer: false, //
|
|
187
|
+
autoRemoveDockerContainer: false, // Always remove docker container after exit (compatibility alias)
|
|
188
|
+
alwaysCleanupContainer: false, // Explicitly request default always-cleanup docker policy
|
|
189
|
+
keepContainer: false, // Keep docker container filesystem after exit
|
|
190
|
+
keepContainerOnFail: false, // Keep docker container filesystem only when command fails
|
|
185
191
|
shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
|
|
186
192
|
useCommandStream: false, // Use command-stream library for command execution
|
|
187
193
|
status: null, // UUID to show status for
|
|
@@ -449,6 +455,24 @@ function parseOption(args, index, options) {
|
|
|
449
455
|
return 1;
|
|
450
456
|
}
|
|
451
457
|
|
|
458
|
+
// --always-cleanup-container
|
|
459
|
+
if (arg === '--always-cleanup-container') {
|
|
460
|
+
options.alwaysCleanupContainer = true;
|
|
461
|
+
return 1;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// --keep-container
|
|
465
|
+
if (arg === '--keep-container') {
|
|
466
|
+
options.keepContainer = true;
|
|
467
|
+
return 1;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// --keep-container-on-fail
|
|
471
|
+
if (arg === '--keep-container-on-fail') {
|
|
472
|
+
options.keepContainerOnFail = true;
|
|
473
|
+
return 1;
|
|
474
|
+
}
|
|
475
|
+
|
|
452
476
|
// --shell <shell>
|
|
453
477
|
if (arg === '--shell') {
|
|
454
478
|
if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
|
|
@@ -649,6 +673,35 @@ function validateDockerRuntimeOptionsRequireDocker(options) {
|
|
|
649
673
|
}
|
|
650
674
|
}
|
|
651
675
|
|
|
676
|
+
function validateDockerCleanupOptions(options, hasDocker) {
|
|
677
|
+
const cleanupFlags = [
|
|
678
|
+
['--auto-remove-docker-container', options.autoRemoveDockerContainer],
|
|
679
|
+
['--always-cleanup-container', options.alwaysCleanupContainer],
|
|
680
|
+
['--keep-container', options.keepContainer],
|
|
681
|
+
['--keep-container-on-fail', options.keepContainerOnFail],
|
|
682
|
+
];
|
|
683
|
+
|
|
684
|
+
for (const [flag, enabled] of cleanupFlags) {
|
|
685
|
+
if (enabled && !hasDocker) {
|
|
686
|
+
throw new Error(
|
|
687
|
+
`${flag} option is only valid when isolation stack includes docker`
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const selectedPolicies = [
|
|
693
|
+
options.autoRemoveDockerContainer || options.alwaysCleanupContainer,
|
|
694
|
+
options.keepContainer,
|
|
695
|
+
options.keepContainerOnFail,
|
|
696
|
+
].filter(Boolean).length;
|
|
697
|
+
|
|
698
|
+
if (selectedPolicies > 1) {
|
|
699
|
+
throw new Error(
|
|
700
|
+
'Cannot combine docker container cleanup policies. Choose only one of --always-cleanup-container, --keep-container, or --keep-container-on-fail.'
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
652
705
|
/**
|
|
653
706
|
* Validate parsed options
|
|
654
707
|
* @param {object} options - Parsed options
|
|
@@ -764,12 +817,7 @@ function validateOptions(options) {
|
|
|
764
817
|
);
|
|
765
818
|
}
|
|
766
819
|
|
|
767
|
-
|
|
768
|
-
if (options.autoRemoveDockerContainer && !stack.includes('docker')) {
|
|
769
|
-
throw new Error(
|
|
770
|
-
'--auto-remove-docker-container option is only valid when isolation stack includes docker'
|
|
771
|
-
);
|
|
772
|
-
}
|
|
820
|
+
validateDockerCleanupOptions(options, stack.includes('docker'));
|
|
773
821
|
|
|
774
822
|
// Docker runtime options (--volume, --mount, --env, --privileged) require docker
|
|
775
823
|
if (!stack.includes('docker')) {
|
|
@@ -785,11 +833,7 @@ function validateOptions(options) {
|
|
|
785
833
|
}
|
|
786
834
|
} else {
|
|
787
835
|
// Validate options that require isolation when no isolation is specified
|
|
788
|
-
|
|
789
|
-
throw new Error(
|
|
790
|
-
'--auto-remove-docker-container option is only valid when isolation stack includes docker'
|
|
791
|
-
);
|
|
792
|
-
}
|
|
836
|
+
validateDockerCleanupOptions(options, false);
|
|
793
837
|
if (options.image) {
|
|
794
838
|
throw new Error(
|
|
795
839
|
'--image option is only valid when isolation stack includes docker'
|
|
@@ -84,8 +84,19 @@ function buildNextLevelCommand(options, command) {
|
|
|
84
84
|
parts.push(`--session-id ${options.sessionId}`);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
if (
|
|
88
|
-
|
|
87
|
+
if (remainingStack.includes('docker')) {
|
|
88
|
+
if (options.autoRemoveDockerContainer) {
|
|
89
|
+
parts.push('--auto-remove-docker-container');
|
|
90
|
+
}
|
|
91
|
+
if (options.alwaysCleanupContainer) {
|
|
92
|
+
parts.push('--always-cleanup-container');
|
|
93
|
+
}
|
|
94
|
+
if (options.keepContainer) {
|
|
95
|
+
parts.push('--keep-container');
|
|
96
|
+
}
|
|
97
|
+
if (options.keepContainerOnFail) {
|
|
98
|
+
parts.push('--keep-container-on-fail');
|
|
99
|
+
}
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
if (options.shell && options.shell !== 'auto') {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const { spawn, spawnSync } = require('child_process');
|
|
2
|
+
const {
|
|
3
|
+
appendLogFile,
|
|
4
|
+
createShellLogFooterSnippet,
|
|
5
|
+
shellQuote,
|
|
6
|
+
} = require('./isolation-log-utils');
|
|
7
|
+
|
|
8
|
+
const DOCKER_CONTAINER_CLEANUP_POLICY = {
|
|
9
|
+
ALWAYS: 'always',
|
|
10
|
+
KEEP: 'keep',
|
|
11
|
+
KEEP_ON_FAIL: 'keep-on-fail',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function getDockerContainerCleanupPolicy(options = {}) {
|
|
15
|
+
if (options.keepContainer) {
|
|
16
|
+
return DOCKER_CONTAINER_CLEANUP_POLICY.KEEP;
|
|
17
|
+
}
|
|
18
|
+
if (options.keepContainerOnFail) {
|
|
19
|
+
return DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL;
|
|
20
|
+
}
|
|
21
|
+
return DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function shouldCleanupDockerContainer(policy, exitCode) {
|
|
25
|
+
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
|
|
29
|
+
return exitCode === 0;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getDockerContainerCleanupInstructions(containerName) {
|
|
35
|
+
return [
|
|
36
|
+
`Container kept for investigation: ${containerName}`,
|
|
37
|
+
`Inspect: docker exec -it ${containerName} sh (if running) or docker start -ai ${containerName}`,
|
|
38
|
+
`Remove when done: docker rm -f ${containerName}`,
|
|
39
|
+
].join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function appendDockerContainerCleanupPolicyMessage(
|
|
43
|
+
message,
|
|
44
|
+
containerName,
|
|
45
|
+
policy
|
|
46
|
+
) {
|
|
47
|
+
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP) {
|
|
48
|
+
return `${message}\n${getDockerContainerCleanupInstructions(containerName)}`;
|
|
49
|
+
}
|
|
50
|
+
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
|
|
51
|
+
return (
|
|
52
|
+
`${message}\nContainer will be removed after successful completion.` +
|
|
53
|
+
`\nContainer will be kept if the command fails.` +
|
|
54
|
+
`\nRemove when done: docker rm -f ${containerName}`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return `${message}\nContainer will be removed after command completes.`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function removeDockerContainer(containerName, logPath = null) {
|
|
61
|
+
const result = spawnSync('docker', ['rm', '-f', containerName], {
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
64
|
+
});
|
|
65
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`;
|
|
66
|
+
if (logPath && output) {
|
|
67
|
+
appendLogFile(logPath, output.endsWith('\n') ? output : `${output}\n`);
|
|
68
|
+
}
|
|
69
|
+
return !result.error && result.status === 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildDetachedDockerCompletionScript(containerName, policy, logPath) {
|
|
73
|
+
const quotedName = shellQuote(containerName);
|
|
74
|
+
const parts = [];
|
|
75
|
+
|
|
76
|
+
if (logPath) {
|
|
77
|
+
const quotedLogPath = shellQuote(logPath);
|
|
78
|
+
parts.push(`docker logs -f ${quotedName} >> ${quotedLogPath} 2>&1`);
|
|
79
|
+
parts.push(
|
|
80
|
+
`__start_command_exit=$(docker inspect -f '{{.State.ExitCode}}' ${quotedName} 2>/dev/null || printf '%s' '-1')`
|
|
81
|
+
);
|
|
82
|
+
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
83
|
+
parts.push(`docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true`);
|
|
84
|
+
} else if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
|
|
85
|
+
parts.push(
|
|
86
|
+
`if [ "$__start_command_exit" -eq 0 ] 2>/dev/null; then docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; fi`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
parts.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
|
|
90
|
+
} else {
|
|
91
|
+
parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
|
|
92
|
+
parts.push(
|
|
93
|
+
`__start_command_exit=$(docker inspect -f '{{.State.ExitCode}}' ${quotedName} 2>/dev/null || printf '%s' '-1')`
|
|
94
|
+
);
|
|
95
|
+
if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
|
|
96
|
+
parts.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
|
|
97
|
+
} else if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
|
|
98
|
+
parts.push(
|
|
99
|
+
`if [ "$__start_command_exit" -eq 0 ] 2>/dev/null; then docker rm -f ${quotedName} >/dev/null 2>&1 || true; fi`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return parts.join('; ');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function startDetachedDockerCompletionWatcher(containerName, policy, logPath) {
|
|
108
|
+
const watcher = spawn(
|
|
109
|
+
'sh',
|
|
110
|
+
['-c', buildDetachedDockerCompletionScript(containerName, policy, logPath)],
|
|
111
|
+
{
|
|
112
|
+
detached: true,
|
|
113
|
+
stdio: 'ignore',
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
watcher.unref();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function spawnAttachedDocker(dockerArgs, logPath) {
|
|
120
|
+
if (!logPath) {
|
|
121
|
+
return spawn('docker', dockerArgs, { stdio: 'inherit' });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const child = spawn('docker', dockerArgs, {
|
|
125
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
126
|
+
});
|
|
127
|
+
const tee = (chunk, stream) => {
|
|
128
|
+
stream.write(chunk);
|
|
129
|
+
appendLogFile(logPath, chunk.toString());
|
|
130
|
+
};
|
|
131
|
+
child.stdout.on('data', (chunk) => tee(chunk, process.stdout));
|
|
132
|
+
child.stderr.on('data', (chunk) => tee(chunk, process.stderr));
|
|
133
|
+
return child;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
DOCKER_CONTAINER_CLEANUP_POLICY,
|
|
138
|
+
getDockerContainerCleanupPolicy,
|
|
139
|
+
shouldCleanupDockerContainer,
|
|
140
|
+
getDockerContainerCleanupInstructions,
|
|
141
|
+
appendDockerContainerCleanupPolicyMessage,
|
|
142
|
+
removeDockerContainer,
|
|
143
|
+
startDetachedDockerCompletionWatcher,
|
|
144
|
+
spawnAttachedDocker,
|
|
145
|
+
};
|
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
|
@@ -18,10 +18,19 @@ const {
|
|
|
18
18
|
} = require('./screen-isolation');
|
|
19
19
|
const {
|
|
20
20
|
appendLogFile,
|
|
21
|
-
createShellLogFooterSnippet,
|
|
22
21
|
shellQuote,
|
|
23
22
|
wrapCommandWithLogFooter,
|
|
24
23
|
} = require('./isolation-log-utils');
|
|
24
|
+
const {
|
|
25
|
+
DOCKER_CONTAINER_CLEANUP_POLICY,
|
|
26
|
+
getDockerContainerCleanupPolicy,
|
|
27
|
+
shouldCleanupDockerContainer,
|
|
28
|
+
getDockerContainerCleanupInstructions,
|
|
29
|
+
appendDockerContainerCleanupPolicyMessage,
|
|
30
|
+
removeDockerContainer,
|
|
31
|
+
startDetachedDockerCompletionWatcher,
|
|
32
|
+
spawnAttachedDocker,
|
|
33
|
+
} = require('./docker-cleanup');
|
|
25
34
|
|
|
26
35
|
/**
|
|
27
36
|
* Check if a command is available on the system
|
|
@@ -554,7 +563,7 @@ function buildDockerRuntimeMetadata(options = {}) {
|
|
|
554
563
|
/**
|
|
555
564
|
* Run command in Docker container
|
|
556
565
|
* @param {string} command - Command to execute
|
|
557
|
-
* @param {object} options - Options (image, session/name, detached, user, keepAlive,
|
|
566
|
+
* @param {object} options - Options (image, session/name, detached, user, keepAlive, container cleanup policy, volumes, mounts, env, privileged)
|
|
558
567
|
* @returns {Promise<{success: boolean, containerName: string, message: string}>}
|
|
559
568
|
*/
|
|
560
569
|
function runInDocker(command, options = {}) {
|
|
@@ -587,8 +596,11 @@ function runInDocker(command, options = {}) {
|
|
|
587
596
|
}
|
|
588
597
|
|
|
589
598
|
const containerName = options.session || generateSessionName('docker');
|
|
599
|
+
const cleanupPolicy = getDockerContainerCleanupPolicy(options);
|
|
590
600
|
if (!dockerImageExists(options.image)) {
|
|
591
|
-
|
|
601
|
+
// Pass logPath so the image-preparation phase (docker pull) is recorded in
|
|
602
|
+
// the session log, keeping it a gap-free record of the run (issue #138).
|
|
603
|
+
const pullResult = dockerPullImage(options.image, options.logPath);
|
|
592
604
|
if (!pullResult.success) {
|
|
593
605
|
return Promise.resolve({
|
|
594
606
|
success: false,
|
|
@@ -615,9 +627,6 @@ function runInDocker(command, options = {}) {
|
|
|
615
627
|
dockerArgs.push('-i', '-t');
|
|
616
628
|
}
|
|
617
629
|
dockerArgs.push('--name', containerName);
|
|
618
|
-
if (options.autoRemoveDockerContainer) {
|
|
619
|
-
dockerArgs.push('--rm');
|
|
620
|
-
}
|
|
621
630
|
|
|
622
631
|
if (options.user) {
|
|
623
632
|
dockerArgs.push('--user', options.user);
|
|
@@ -645,6 +654,7 @@ function runInDocker(command, options = {}) {
|
|
|
645
654
|
console.log(
|
|
646
655
|
`[DEBUG] autoRemoveDockerContainer: ${options.autoRemoveDockerContainer || false}`
|
|
647
656
|
);
|
|
657
|
+
console.log(`[DEBUG] docker cleanup policy: ${cleanupPolicy}`);
|
|
648
658
|
}
|
|
649
659
|
|
|
650
660
|
const dockerResult = spawnSync('docker', dockerArgs, {
|
|
@@ -665,18 +675,11 @@ function runInDocker(command, options = {}) {
|
|
|
665
675
|
|
|
666
676
|
const containerId = dockerResult.stdout.trim();
|
|
667
677
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
].join('; ');
|
|
674
|
-
const logger = spawn('sh', ['-c', loggerScript], {
|
|
675
|
-
detached: true,
|
|
676
|
-
stdio: 'ignore',
|
|
677
|
-
});
|
|
678
|
-
logger.unref();
|
|
679
|
-
}
|
|
678
|
+
startDetachedDockerCompletionWatcher(
|
|
679
|
+
containerName,
|
|
680
|
+
cleanupPolicy,
|
|
681
|
+
options.logPath
|
|
682
|
+
);
|
|
680
683
|
|
|
681
684
|
let message = `Command started in detached docker container: ${containerName}`;
|
|
682
685
|
message += `\nContainer ID: ${containerId.substring(0, 12)}`;
|
|
@@ -685,11 +688,11 @@ function runInDocker(command, options = {}) {
|
|
|
685
688
|
} else {
|
|
686
689
|
message += `\nContainer will exit automatically after command completes.`;
|
|
687
690
|
}
|
|
688
|
-
|
|
689
|
-
message
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
691
|
+
message = appendDockerContainerCleanupPolicyMessage(
|
|
692
|
+
message,
|
|
693
|
+
containerName,
|
|
694
|
+
cleanupPolicy
|
|
695
|
+
);
|
|
693
696
|
message += `\nAttach with: docker attach ${containerName}`;
|
|
694
697
|
message += `\nView logs: docker logs ${containerName}`;
|
|
695
698
|
if (options.logPath) {
|
|
@@ -703,7 +706,9 @@ function runInDocker(command, options = {}) {
|
|
|
703
706
|
message,
|
|
704
707
|
});
|
|
705
708
|
} else {
|
|
706
|
-
const dockerArgs = ['run'
|
|
709
|
+
const dockerArgs = ['run'];
|
|
710
|
+
dockerArgs.push(hasTTY() ? '-it' : '-i');
|
|
711
|
+
dockerArgs.push('--name', containerName);
|
|
707
712
|
if (options.user) {
|
|
708
713
|
dockerArgs.push('--user', options.user);
|
|
709
714
|
}
|
|
@@ -737,13 +742,14 @@ function runInDocker(command, options = {}) {
|
|
|
737
742
|
|
|
738
743
|
return new Promise((resolve) => {
|
|
739
744
|
const startTime = Date.now();
|
|
740
|
-
const child =
|
|
745
|
+
const child = spawnAttachedDocker(dockerArgs, options.logPath);
|
|
741
746
|
|
|
742
747
|
child.on('exit', (code) => {
|
|
743
748
|
const durationMs = Date.now() - startTime;
|
|
744
|
-
|
|
749
|
+
const exitCode = code ?? 1;
|
|
750
|
+
let message = `Docker container "${containerName}" exited with code ${exitCode}`;
|
|
745
751
|
// Bare shell exited non-zero quickly → startup file error; suggest --norc (issue #84).
|
|
746
|
-
if (isBareShell &&
|
|
752
|
+
if (isBareShell && exitCode !== 0 && durationMs < 3000) {
|
|
747
753
|
const shell0 = command.trim().split(/\s+/)[0];
|
|
748
754
|
// prettier-ignore
|
|
749
755
|
const norc = path.basename(shell0) === 'zsh' ? '--no-rcs' : '--norc';
|
|
@@ -751,11 +757,28 @@ function runInDocker(command, options = {}) {
|
|
|
751
757
|
console.log(hint);
|
|
752
758
|
message += `\n${hint}`;
|
|
753
759
|
}
|
|
760
|
+
|
|
761
|
+
if (shouldCleanupDockerContainer(cleanupPolicy, exitCode)) {
|
|
762
|
+
if (removeDockerContainer(containerName, options.logPath)) {
|
|
763
|
+
message += `\nContainer removed after completion.`;
|
|
764
|
+
} else {
|
|
765
|
+
message += `\nWarning: failed to remove container automatically.`;
|
|
766
|
+
message += `\nRemove when done: docker rm -f ${containerName}`;
|
|
767
|
+
}
|
|
768
|
+
} else if (cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP) {
|
|
769
|
+
message += `\n${getDockerContainerCleanupInstructions(containerName)}`;
|
|
770
|
+
} else if (
|
|
771
|
+
cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
|
|
772
|
+
) {
|
|
773
|
+
message += `\nContainer kept because the command failed.`;
|
|
774
|
+
message += `\nRemove when done: docker rm -f ${containerName}`;
|
|
775
|
+
}
|
|
776
|
+
|
|
754
777
|
resolve({
|
|
755
|
-
success:
|
|
778
|
+
success: exitCode === 0,
|
|
756
779
|
containerName,
|
|
757
780
|
message,
|
|
758
|
-
exitCode
|
|
781
|
+
exitCode,
|
|
759
782
|
});
|
|
760
783
|
});
|
|
761
784
|
|
package/src/lib/usage.js
CHANGED
|
@@ -18,7 +18,10 @@ Options:
|
|
|
18
18
|
--isolated-user, -u [name] Create isolated user with same permissions
|
|
19
19
|
--keep-user Keep isolated user after command completes
|
|
20
20
|
--keep-alive, -k Keep isolation environment alive after command exits
|
|
21
|
-
--auto-remove-docker-container
|
|
21
|
+
--auto-remove-docker-container Always remove docker container after exit (compatibility alias)
|
|
22
|
+
--always-cleanup-container Always remove docker container after exit (default)
|
|
23
|
+
--keep-container Keep docker container filesystem after exit
|
|
24
|
+
--keep-container-on-fail Remove successful docker containers, keep failed ones
|
|
22
25
|
--shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
|
|
23
26
|
--use-command-stream Use command-stream library for execution (experimental)
|
|
24
27
|
--status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
|