start-command 0.29.2 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/bin/cli.js +7 -0
- package/src/lib/args-parser.js +58 -14
- package/src/lib/command-builder.js +13 -2
- package/src/lib/docker-cleanup.js +145 -0
- package/src/lib/execution-control.js +3 -3
- package/src/lib/isolation.js +49 -28
- package/src/lib/usage.js +5 -2
- 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/execution-control.js +40 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.30.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 1a040d6: Use `docker stop` for detached Docker `--stop` control so containers follow Docker's graceful stop lifecycle instead of receiving a raw `SIGINT`.
|
|
8
|
+
|
|
9
|
+
## 0.30.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 0.29.2
|
|
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)
|
|
@@ -27,7 +30,7 @@
|
|
|
27
30
|
* --list List all tracked command executions
|
|
28
31
|
* --upload-log <uuid-or-session> Upload the stored log for a tracked execution
|
|
29
32
|
* --output-format <format> Output format for status/list (links-notation, json, text)
|
|
30
|
-
* --stop <uuid-or-session-name>
|
|
33
|
+
* --stop <uuid-or-session-name> Ask a detached execution to stop gracefully
|
|
31
34
|
* --terminate <uuid-or-session-name> Terminate a detached execution immediately
|
|
32
35
|
* --cleanup Clean up stale "executing" records (processes that crashed or were killed)
|
|
33
36
|
* --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
|
|
@@ -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
|
+
};
|
|
@@ -197,9 +197,9 @@ function getControlCommand(record, action) {
|
|
|
197
197
|
case 'docker':
|
|
198
198
|
return {
|
|
199
199
|
command: 'docker',
|
|
200
|
-
args: ['
|
|
201
|
-
method: '
|
|
202
|
-
message: `
|
|
200
|
+
args: ['stop', sessionName],
|
|
201
|
+
method: 'DOCKER_STOP',
|
|
202
|
+
message: `Requested graceful stop for detached docker container: ${sessionName}`,
|
|
203
203
|
};
|
|
204
204
|
default:
|
|
205
205
|
return {
|
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,6 +596,7 @@ 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
|
|
592
602
|
// the session log, keeping it a gap-free record of the run (issue #138).
|
|
@@ -617,9 +627,6 @@ function runInDocker(command, options = {}) {
|
|
|
617
627
|
dockerArgs.push('-i', '-t');
|
|
618
628
|
}
|
|
619
629
|
dockerArgs.push('--name', containerName);
|
|
620
|
-
if (options.autoRemoveDockerContainer) {
|
|
621
|
-
dockerArgs.push('--rm');
|
|
622
|
-
}
|
|
623
630
|
|
|
624
631
|
if (options.user) {
|
|
625
632
|
dockerArgs.push('--user', options.user);
|
|
@@ -647,6 +654,7 @@ function runInDocker(command, options = {}) {
|
|
|
647
654
|
console.log(
|
|
648
655
|
`[DEBUG] autoRemoveDockerContainer: ${options.autoRemoveDockerContainer || false}`
|
|
649
656
|
);
|
|
657
|
+
console.log(`[DEBUG] docker cleanup policy: ${cleanupPolicy}`);
|
|
650
658
|
}
|
|
651
659
|
|
|
652
660
|
const dockerResult = spawnSync('docker', dockerArgs, {
|
|
@@ -667,18 +675,11 @@ function runInDocker(command, options = {}) {
|
|
|
667
675
|
|
|
668
676
|
const containerId = dockerResult.stdout.trim();
|
|
669
677
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
].join('; ');
|
|
676
|
-
const logger = spawn('sh', ['-c', loggerScript], {
|
|
677
|
-
detached: true,
|
|
678
|
-
stdio: 'ignore',
|
|
679
|
-
});
|
|
680
|
-
logger.unref();
|
|
681
|
-
}
|
|
678
|
+
startDetachedDockerCompletionWatcher(
|
|
679
|
+
containerName,
|
|
680
|
+
cleanupPolicy,
|
|
681
|
+
options.logPath
|
|
682
|
+
);
|
|
682
683
|
|
|
683
684
|
let message = `Command started in detached docker container: ${containerName}`;
|
|
684
685
|
message += `\nContainer ID: ${containerId.substring(0, 12)}`;
|
|
@@ -687,11 +688,11 @@ function runInDocker(command, options = {}) {
|
|
|
687
688
|
} else {
|
|
688
689
|
message += `\nContainer will exit automatically after command completes.`;
|
|
689
690
|
}
|
|
690
|
-
|
|
691
|
-
message
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
691
|
+
message = appendDockerContainerCleanupPolicyMessage(
|
|
692
|
+
message,
|
|
693
|
+
containerName,
|
|
694
|
+
cleanupPolicy
|
|
695
|
+
);
|
|
695
696
|
message += `\nAttach with: docker attach ${containerName}`;
|
|
696
697
|
message += `\nView logs: docker logs ${containerName}`;
|
|
697
698
|
if (options.logPath) {
|
|
@@ -705,7 +706,9 @@ function runInDocker(command, options = {}) {
|
|
|
705
706
|
message,
|
|
706
707
|
});
|
|
707
708
|
} else {
|
|
708
|
-
const dockerArgs = ['run'
|
|
709
|
+
const dockerArgs = ['run'];
|
|
710
|
+
dockerArgs.push(hasTTY() ? '-it' : '-i');
|
|
711
|
+
dockerArgs.push('--name', containerName);
|
|
709
712
|
if (options.user) {
|
|
710
713
|
dockerArgs.push('--user', options.user);
|
|
711
714
|
}
|
|
@@ -739,13 +742,14 @@ function runInDocker(command, options = {}) {
|
|
|
739
742
|
|
|
740
743
|
return new Promise((resolve) => {
|
|
741
744
|
const startTime = Date.now();
|
|
742
|
-
const child =
|
|
745
|
+
const child = spawnAttachedDocker(dockerArgs, options.logPath);
|
|
743
746
|
|
|
744
747
|
child.on('exit', (code) => {
|
|
745
748
|
const durationMs = Date.now() - startTime;
|
|
746
|
-
|
|
749
|
+
const exitCode = code ?? 1;
|
|
750
|
+
let message = `Docker container "${containerName}" exited with code ${exitCode}`;
|
|
747
751
|
// Bare shell exited non-zero quickly → startup file error; suggest --norc (issue #84).
|
|
748
|
-
if (isBareShell &&
|
|
752
|
+
if (isBareShell && exitCode !== 0 && durationMs < 3000) {
|
|
749
753
|
const shell0 = command.trim().split(/\s+/)[0];
|
|
750
754
|
// prettier-ignore
|
|
751
755
|
const norc = path.basename(shell0) === 'zsh' ? '--no-rcs' : '--norc';
|
|
@@ -753,11 +757,28 @@ function runInDocker(command, options = {}) {
|
|
|
753
757
|
console.log(hint);
|
|
754
758
|
message += `\n${hint}`;
|
|
755
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
|
+
|
|
756
777
|
resolve({
|
|
757
|
-
success:
|
|
778
|
+
success: exitCode === 0,
|
|
758
779
|
containerName,
|
|
759
780
|
message,
|
|
760
|
-
exitCode
|
|
781
|
+
exitCode,
|
|
761
782
|
});
|
|
762
783
|
});
|
|
763
784
|
|
package/src/lib/usage.js
CHANGED
|
@@ -18,13 +18,16 @@ 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)
|
|
25
28
|
--list List all tracked executions (--output-format: links-notation|json|text)
|
|
26
29
|
--upload-log <id> Upload the stored log for an execution UUID or session name
|
|
27
|
-
--stop <id>
|
|
30
|
+
--stop <id> Ask a detached isolated execution to stop gracefully
|
|
28
31
|
--terminate <id> Terminate a detached isolated execution immediately
|
|
29
32
|
--cleanup Clean up stale "executing" records (crashed/killed processes)
|
|
30
33
|
--cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('assert');
|
|
5
|
+
const { parseArgs } = require('../src/lib/args-parser');
|
|
6
|
+
|
|
7
|
+
function parseDocker(args = []) {
|
|
8
|
+
return parseArgs([
|
|
9
|
+
'--isolated',
|
|
10
|
+
'docker',
|
|
11
|
+
'--image',
|
|
12
|
+
'alpine',
|
|
13
|
+
...args,
|
|
14
|
+
'--',
|
|
15
|
+
'npm',
|
|
16
|
+
'test',
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('docker container cleanup options', () => {
|
|
21
|
+
it('should parse --auto-remove-docker-container flag', () => {
|
|
22
|
+
const result = parseDocker(['--auto-remove-docker-container']);
|
|
23
|
+
assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should parse --always-cleanup-container flag', () => {
|
|
27
|
+
const result = parseDocker(['--always-cleanup-container']);
|
|
28
|
+
assert.strictEqual(result.wrapperOptions.alwaysCleanupContainer, true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should parse --keep-container flag', () => {
|
|
32
|
+
const result = parseDocker(['--keep-container']);
|
|
33
|
+
assert.strictEqual(result.wrapperOptions.keepContainer, true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should parse --keep-container-on-fail flag', () => {
|
|
37
|
+
const result = parseDocker(['--keep-container-on-fail']);
|
|
38
|
+
assert.strictEqual(result.wrapperOptions.keepContainerOnFail, true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should default cleanup flags to false', () => {
|
|
42
|
+
const result = parseDocker();
|
|
43
|
+
assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, false);
|
|
44
|
+
assert.strictEqual(result.wrapperOptions.alwaysCleanupContainer, false);
|
|
45
|
+
assert.strictEqual(result.wrapperOptions.keepContainer, false);
|
|
46
|
+
assert.strictEqual(result.wrapperOptions.keepContainerOnFail, false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should throw error for docker cleanup options without docker isolation', () => {
|
|
50
|
+
assert.throws(() => {
|
|
51
|
+
parseArgs([
|
|
52
|
+
'-i',
|
|
53
|
+
'tmux',
|
|
54
|
+
'--auto-remove-docker-container',
|
|
55
|
+
'--',
|
|
56
|
+
'npm',
|
|
57
|
+
'test',
|
|
58
|
+
]);
|
|
59
|
+
}, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
|
|
60
|
+
assert.throws(() => {
|
|
61
|
+
parseArgs([
|
|
62
|
+
'-i',
|
|
63
|
+
'tmux',
|
|
64
|
+
'--always-cleanup-container',
|
|
65
|
+
'--',
|
|
66
|
+
'npm',
|
|
67
|
+
'test',
|
|
68
|
+
]);
|
|
69
|
+
}, /--always-cleanup-container option is only valid when isolation stack includes docker/);
|
|
70
|
+
assert.throws(() => {
|
|
71
|
+
parseArgs(['-i', 'tmux', '--keep-container', '--', 'npm', 'test']);
|
|
72
|
+
}, /--keep-container option is only valid when isolation stack includes docker/);
|
|
73
|
+
assert.throws(() => {
|
|
74
|
+
parseArgs([
|
|
75
|
+
'-i',
|
|
76
|
+
'tmux',
|
|
77
|
+
'--keep-container-on-fail',
|
|
78
|
+
'--',
|
|
79
|
+
'npm',
|
|
80
|
+
'test',
|
|
81
|
+
]);
|
|
82
|
+
}, /--keep-container-on-fail option is only valid when isolation stack includes docker/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should throw error for docker cleanup options without isolation', () => {
|
|
86
|
+
assert.throws(() => {
|
|
87
|
+
parseArgs(['--auto-remove-docker-container', '--', 'npm', 'test']);
|
|
88
|
+
}, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
|
|
89
|
+
assert.throws(() => {
|
|
90
|
+
parseArgs(['--always-cleanup-container', '--', 'npm', 'test']);
|
|
91
|
+
}, /--always-cleanup-container option is only valid when isolation stack includes docker/);
|
|
92
|
+
assert.throws(() => {
|
|
93
|
+
parseArgs(['--keep-container', '--', 'npm', 'test']);
|
|
94
|
+
}, /--keep-container option is only valid when isolation stack includes docker/);
|
|
95
|
+
assert.throws(() => {
|
|
96
|
+
parseArgs(['--keep-container-on-fail', '--', 'npm', 'test']);
|
|
97
|
+
}, /--keep-container-on-fail option is only valid when isolation stack includes docker/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should work with keep-alive and auto-remove-docker-container', () => {
|
|
101
|
+
const result = parseArgs([
|
|
102
|
+
'-i',
|
|
103
|
+
'docker',
|
|
104
|
+
'--image',
|
|
105
|
+
'node:20',
|
|
106
|
+
'-k',
|
|
107
|
+
'--auto-remove-docker-container',
|
|
108
|
+
'--',
|
|
109
|
+
'npm',
|
|
110
|
+
'test',
|
|
111
|
+
]);
|
|
112
|
+
assert.strictEqual(result.wrapperOptions.isolated, 'docker');
|
|
113
|
+
assert.strictEqual(result.wrapperOptions.image, 'node:20');
|
|
114
|
+
assert.strictEqual(result.wrapperOptions.keepAlive, true);
|
|
115
|
+
assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should reject conflicting docker cleanup options', () => {
|
|
119
|
+
assert.throws(() => {
|
|
120
|
+
parseArgs([
|
|
121
|
+
'-i',
|
|
122
|
+
'docker',
|
|
123
|
+
'--keep-container',
|
|
124
|
+
'--always-cleanup-container',
|
|
125
|
+
'--',
|
|
126
|
+
'npm',
|
|
127
|
+
'test',
|
|
128
|
+
]);
|
|
129
|
+
}, /Cannot combine docker container cleanup policies/);
|
|
130
|
+
|
|
131
|
+
assert.throws(() => {
|
|
132
|
+
parseArgs([
|
|
133
|
+
'-i',
|
|
134
|
+
'docker',
|
|
135
|
+
'--keep-container',
|
|
136
|
+
'--keep-container-on-fail',
|
|
137
|
+
'--',
|
|
138
|
+
'npm',
|
|
139
|
+
'test',
|
|
140
|
+
]);
|
|
141
|
+
}, /Cannot combine docker container cleanup policies/);
|
|
142
|
+
});
|
|
143
|
+
});
|
package/test/args-parser.js
CHANGED
|
@@ -343,75 +343,6 @@ describe('parseArgs', () => {
|
|
|
343
343
|
});
|
|
344
344
|
});
|
|
345
345
|
|
|
346
|
-
describe('auto-remove-docker-container option', () => {
|
|
347
|
-
it('should parse --auto-remove-docker-container flag', () => {
|
|
348
|
-
const result = parseArgs([
|
|
349
|
-
'--isolated',
|
|
350
|
-
'docker',
|
|
351
|
-
'--image',
|
|
352
|
-
'alpine',
|
|
353
|
-
'--auto-remove-docker-container',
|
|
354
|
-
'--',
|
|
355
|
-
'npm',
|
|
356
|
-
'test',
|
|
357
|
-
]);
|
|
358
|
-
assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
it('should default autoRemoveDockerContainer to false', () => {
|
|
362
|
-
const result = parseArgs([
|
|
363
|
-
'-i',
|
|
364
|
-
'docker',
|
|
365
|
-
'--image',
|
|
366
|
-
'alpine',
|
|
367
|
-
'--',
|
|
368
|
-
'npm',
|
|
369
|
-
'test',
|
|
370
|
-
]);
|
|
371
|
-
assert.strictEqual(
|
|
372
|
-
result.wrapperOptions.autoRemoveDockerContainer,
|
|
373
|
-
false
|
|
374
|
-
);
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
it('should throw error for auto-remove-docker-container without docker isolation', () => {
|
|
378
|
-
assert.throws(() => {
|
|
379
|
-
parseArgs([
|
|
380
|
-
'-i',
|
|
381
|
-
'tmux',
|
|
382
|
-
'--auto-remove-docker-container',
|
|
383
|
-
'--',
|
|
384
|
-
'npm',
|
|
385
|
-
'test',
|
|
386
|
-
]);
|
|
387
|
-
}, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
it('should throw error for auto-remove-docker-container without isolation', () => {
|
|
391
|
-
assert.throws(() => {
|
|
392
|
-
parseArgs(['--auto-remove-docker-container', '--', 'npm', 'test']);
|
|
393
|
-
}, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
it('should work with keep-alive and auto-remove-docker-container', () => {
|
|
397
|
-
const result = parseArgs([
|
|
398
|
-
'-i',
|
|
399
|
-
'docker',
|
|
400
|
-
'--image',
|
|
401
|
-
'node:20',
|
|
402
|
-
'-k',
|
|
403
|
-
'--auto-remove-docker-container',
|
|
404
|
-
'--',
|
|
405
|
-
'npm',
|
|
406
|
-
'test',
|
|
407
|
-
]);
|
|
408
|
-
assert.strictEqual(result.wrapperOptions.isolated, 'docker');
|
|
409
|
-
assert.strictEqual(result.wrapperOptions.image, 'node:20');
|
|
410
|
-
assert.strictEqual(result.wrapperOptions.keepAlive, true);
|
|
411
|
-
assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
|
|
412
|
-
});
|
|
413
|
-
});
|
|
414
|
-
|
|
415
346
|
describe('command without separator', () => {
|
|
416
347
|
it('should parse command after options without separator', () => {
|
|
417
348
|
const result = parseArgs(['-i', 'tmux', '-d', 'npm', 'start']);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('assert');
|
|
5
|
+
const { buildNextLevelCommand } = require('../src/lib/command-builder');
|
|
6
|
+
|
|
7
|
+
describe('command-builder docker cleanup forwarding', () => {
|
|
8
|
+
it('forwards cleanup policy flags when a remaining level uses docker', () => {
|
|
9
|
+
const command = buildNextLevelCommand(
|
|
10
|
+
{
|
|
11
|
+
isolatedStack: ['tmux', 'docker'],
|
|
12
|
+
imageStack: [null, 'alpine'],
|
|
13
|
+
keepContainerOnFail: true,
|
|
14
|
+
},
|
|
15
|
+
'npm test'
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
assert.match(command, /--isolated "docker"/);
|
|
19
|
+
assert.match(command, /--keep-container-on-fail/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('does not forward docker cleanup flags after the docker level is consumed', () => {
|
|
23
|
+
const command = buildNextLevelCommand(
|
|
24
|
+
{
|
|
25
|
+
isolatedStack: ['docker', 'tmux'],
|
|
26
|
+
imageStack: ['alpine', null],
|
|
27
|
+
keepContainer: true,
|
|
28
|
+
},
|
|
29
|
+
'npm test'
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
assert.match(command, /--isolated "tmux"/);
|
|
33
|
+
assert.doesNotMatch(command, /--keep-container/);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Tests for Docker
|
|
3
|
+
* Tests for Docker container cleanup behavior
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const { describe, it } = require('node:test');
|
|
7
7
|
const assert = require('assert');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
8
11
|
const { isCommandAvailable } = require('../src/lib/isolation');
|
|
9
12
|
const { runInDocker } = require('../src/lib/isolation');
|
|
10
13
|
const { execSync } = require('child_process');
|
|
@@ -26,9 +29,9 @@ async function waitFor(conditionFn, timeout = 5000, interval = 100) {
|
|
|
26
29
|
const { canRunLinuxDockerImages } = require('../src/lib/isolation');
|
|
27
30
|
const DOCKER_TEST_TIMEOUT = 20000;
|
|
28
31
|
|
|
29
|
-
describe('Docker
|
|
30
|
-
// These tests verify
|
|
31
|
-
//
|
|
32
|
+
describe('Docker Container Cleanup Policy', () => {
|
|
33
|
+
// These tests verify that docker isolation removes finished containers by
|
|
34
|
+
// default while still providing explicit flags to keep them for investigation.
|
|
32
35
|
|
|
33
36
|
describe('auto-remove enabled', () => {
|
|
34
37
|
it(
|
|
@@ -55,8 +58,8 @@ describe('Docker Auto-Remove Container Feature', () => {
|
|
|
55
58
|
|
|
56
59
|
assert.strictEqual(result.success, true);
|
|
57
60
|
assert.ok(
|
|
58
|
-
result.message.includes('
|
|
59
|
-
'Message should indicate
|
|
61
|
+
result.message.includes('will be removed'),
|
|
62
|
+
'Message should indicate cleanup'
|
|
60
63
|
);
|
|
61
64
|
|
|
62
65
|
// Wait for container to finish and be removed
|
|
@@ -99,9 +102,9 @@ describe('Docker Auto-Remove Container Feature', () => {
|
|
|
99
102
|
);
|
|
100
103
|
});
|
|
101
104
|
|
|
102
|
-
describe('
|
|
105
|
+
describe('default cleanup', () => {
|
|
103
106
|
it(
|
|
104
|
-
'should
|
|
107
|
+
'should remove container filesystem by default',
|
|
105
108
|
{ timeout: DOCKER_TEST_TIMEOUT },
|
|
106
109
|
async () => {
|
|
107
110
|
if (!canRunLinuxDockerImages()) {
|
|
@@ -111,9 +114,10 @@ describe('Docker Auto-Remove Container Feature', () => {
|
|
|
111
114
|
return;
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
const containerName = `test-
|
|
117
|
+
const containerName = `test-default-cleanup-${Date.now()}`;
|
|
115
118
|
|
|
116
|
-
// Run command without
|
|
119
|
+
// Run command without any cleanup flag. The default should still remove
|
|
120
|
+
// the container after the command finishes.
|
|
117
121
|
const result = await runInDocker('echo "test" && sleep 0.1', {
|
|
118
122
|
image: 'alpine:latest',
|
|
119
123
|
session: containerName,
|
|
@@ -124,12 +128,76 @@ describe('Docker Auto-Remove Container Feature', () => {
|
|
|
124
128
|
|
|
125
129
|
assert.strictEqual(result.success, true);
|
|
126
130
|
assert.ok(
|
|
127
|
-
result.message.includes('
|
|
128
|
-
'Message should indicate
|
|
131
|
+
result.message.includes('will be removed'),
|
|
132
|
+
'Message should indicate default cleanup'
|
|
129
133
|
);
|
|
130
134
|
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
const containerRemoved = await waitFor(() => {
|
|
136
|
+
try {
|
|
137
|
+
execSync(`docker inspect -f '{{.State.Status}}' ${containerName}`, {
|
|
138
|
+
encoding: 'utf8',
|
|
139
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
140
|
+
});
|
|
141
|
+
return false;
|
|
142
|
+
} catch {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}, 10000);
|
|
146
|
+
|
|
147
|
+
assert.ok(
|
|
148
|
+
containerRemoved,
|
|
149
|
+
'Container should be removed after exit by default'
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const allContainers = execSync('docker ps -a', {
|
|
154
|
+
encoding: 'utf8',
|
|
155
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
156
|
+
});
|
|
157
|
+
assert.ok(
|
|
158
|
+
!allContainers.includes(containerName),
|
|
159
|
+
'Container should NOT appear in docker ps -a after default cleanup'
|
|
160
|
+
);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
assert.fail(`Failed to verify container cleanup: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('keep-container opt-out', () => {
|
|
169
|
+
it(
|
|
170
|
+
'should preserve container filesystem when keepContainer is true',
|
|
171
|
+
{ timeout: DOCKER_TEST_TIMEOUT },
|
|
172
|
+
async () => {
|
|
173
|
+
if (!canRunLinuxDockerImages()) {
|
|
174
|
+
console.log(
|
|
175
|
+
' Skipping: docker not available, daemon not running, or Linux containers not supported'
|
|
176
|
+
);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const containerName = `test-keep-container-${Date.now()}`;
|
|
181
|
+
|
|
182
|
+
const result = await runInDocker('echo "test" && sleep 0.1', {
|
|
183
|
+
image: 'alpine:latest',
|
|
184
|
+
session: containerName,
|
|
185
|
+
detached: true,
|
|
186
|
+
keepAlive: false,
|
|
187
|
+
keepContainer: true,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
assert.strictEqual(result.success, true);
|
|
191
|
+
assert.ok(
|
|
192
|
+
result.message.includes('Container kept for investigation'),
|
|
193
|
+
'Message should explain that the container is kept'
|
|
194
|
+
);
|
|
195
|
+
assert.ok(
|
|
196
|
+
result.message.includes(`docker rm -f ${containerName}`),
|
|
197
|
+
'Message should include cleanup command'
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const containerExited = await waitFor(() => {
|
|
133
201
|
try {
|
|
134
202
|
const status = execSync(
|
|
135
203
|
`docker inspect -f '{{.State.Status}}' ${containerName}`,
|
|
@@ -144,26 +212,66 @@ describe('Docker Auto-Remove Container Feature', () => {
|
|
|
144
212
|
}
|
|
145
213
|
}, 10000);
|
|
146
214
|
|
|
147
|
-
|
|
215
|
+
assert.ok(containerExited, 'Container should remain in exited state');
|
|
216
|
+
|
|
148
217
|
try {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
218
|
+
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
|
|
219
|
+
} catch {
|
|
220
|
+
// Ignore cleanup errors
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
it(
|
|
226
|
+
'should preserve failed containers when keepContainerOnFail is true',
|
|
227
|
+
{ timeout: DOCKER_TEST_TIMEOUT },
|
|
228
|
+
async () => {
|
|
229
|
+
if (!canRunLinuxDockerImages()) {
|
|
157
230
|
console.log(
|
|
158
|
-
'
|
|
159
|
-
);
|
|
160
|
-
} catch (err) {
|
|
161
|
-
assert.fail(
|
|
162
|
-
`Failed to verify container preservation: ${err.message}`
|
|
231
|
+
' Skipping: docker not available, daemon not running, or Linux containers not supported'
|
|
163
232
|
);
|
|
233
|
+
return;
|
|
164
234
|
}
|
|
165
235
|
|
|
166
|
-
|
|
236
|
+
const containerName = `test-keep-on-fail-${Date.now()}`;
|
|
237
|
+
|
|
238
|
+
const result = await runInDocker('echo "test" && exit 7', {
|
|
239
|
+
image: 'alpine:latest',
|
|
240
|
+
session: containerName,
|
|
241
|
+
detached: true,
|
|
242
|
+
keepAlive: false,
|
|
243
|
+
keepContainerOnFail: true,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
assert.strictEqual(result.success, true);
|
|
247
|
+
assert.ok(
|
|
248
|
+
result.message.includes(
|
|
249
|
+
'Container will be kept if the command fails'
|
|
250
|
+
),
|
|
251
|
+
'Message should describe failure retention'
|
|
252
|
+
);
|
|
253
|
+
assert.ok(
|
|
254
|
+
result.message.includes(`docker rm -f ${containerName}`),
|
|
255
|
+
'Message should include cleanup command'
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const containerExited = await waitFor(() => {
|
|
259
|
+
try {
|
|
260
|
+
const status = execSync(
|
|
261
|
+
`docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' ${containerName}`,
|
|
262
|
+
{
|
|
263
|
+
encoding: 'utf8',
|
|
264
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
265
|
+
}
|
|
266
|
+
).trim();
|
|
267
|
+
return status === 'exited 7';
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}, 10000);
|
|
272
|
+
|
|
273
|
+
assert.ok(containerExited, 'Failed container should be preserved');
|
|
274
|
+
|
|
167
275
|
try {
|
|
168
276
|
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
|
|
169
277
|
} catch {
|
|
@@ -172,4 +280,71 @@ describe('Docker Auto-Remove Container Feature', () => {
|
|
|
172
280
|
}
|
|
173
281
|
);
|
|
174
282
|
});
|
|
283
|
+
|
|
284
|
+
describe('attached mode logging', () => {
|
|
285
|
+
it(
|
|
286
|
+
'should write attached docker output to the provided host log path',
|
|
287
|
+
{ timeout: DOCKER_TEST_TIMEOUT },
|
|
288
|
+
async () => {
|
|
289
|
+
if (!canRunLinuxDockerImages()) {
|
|
290
|
+
console.log(
|
|
291
|
+
' Skipping: docker not available, daemon not running, or Linux containers not supported'
|
|
292
|
+
);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const containerName = `test-attached-log-${Date.now()}`;
|
|
297
|
+
const logPath = path.join(
|
|
298
|
+
os.tmpdir(),
|
|
299
|
+
`start-attached-docker-${process.pid}-${Date.now()}.log`
|
|
300
|
+
);
|
|
301
|
+
fs.writeFileSync(logPath, '=== test log header ===\n');
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const result = await runInDocker("printf 'attached-log-line\\n'", {
|
|
305
|
+
image: 'alpine:latest',
|
|
306
|
+
session: containerName,
|
|
307
|
+
detached: false,
|
|
308
|
+
keepAlive: false,
|
|
309
|
+
logPath,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
assert.strictEqual(result.success, true);
|
|
313
|
+
assert.ok(
|
|
314
|
+
result.message.includes('Container removed after completion'),
|
|
315
|
+
'Attached container should be removed after completion by default'
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const contents = fs.readFileSync(logPath, 'utf8');
|
|
319
|
+
assert.ok(
|
|
320
|
+
contents.includes('attached-log-line'),
|
|
321
|
+
`Attached docker output should be written to host log, got:\n${contents}`
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const containerRemoved = await waitFor(() => {
|
|
325
|
+
try {
|
|
326
|
+
execSync(
|
|
327
|
+
`docker inspect -f '{{.State.Status}}' ${containerName}`,
|
|
328
|
+
{
|
|
329
|
+
encoding: 'utf8',
|
|
330
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
return false;
|
|
334
|
+
} catch {
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
}, 10000);
|
|
338
|
+
assert.ok(containerRemoved, 'Attached container should be removed');
|
|
339
|
+
} finally {
|
|
340
|
+
try {
|
|
341
|
+
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
|
|
342
|
+
} catch {
|
|
343
|
+
// Ignore cleanup errors
|
|
344
|
+
}
|
|
345
|
+
fs.rmSync(logPath, { force: true });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
});
|
|
175
350
|
});
|
|
@@ -136,6 +136,46 @@ describe('execution control', () => {
|
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
+
it('should stop a detached docker container with docker stop', () => {
|
|
140
|
+
const store = createStore();
|
|
141
|
+
store.save(
|
|
142
|
+
createDetachedRecord({
|
|
143
|
+
uuid: 'docker-control-uuid',
|
|
144
|
+
options: {
|
|
145
|
+
isolated: 'docker',
|
|
146
|
+
isolationMode: 'detached',
|
|
147
|
+
sessionName: 'docker-session',
|
|
148
|
+
containerId: 'abc123',
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
const runner = createRunner({
|
|
153
|
+
'docker inspect -f {{.Id}} {{.State.Pid}} docker-session': {
|
|
154
|
+
success: true,
|
|
155
|
+
stdout: 'abcdef 0\n',
|
|
156
|
+
stderr: '',
|
|
157
|
+
status: 0,
|
|
158
|
+
error: null,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const result = controlExecution(
|
|
163
|
+
store,
|
|
164
|
+
'docker-control-uuid',
|
|
165
|
+
ControlAction.STOP,
|
|
166
|
+
runner
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
assert.strictEqual(result.success, true);
|
|
170
|
+
assert.match(result.output, /action stop/);
|
|
171
|
+
assert.match(result.output, /method DOCKER_STOP/);
|
|
172
|
+
assert.match(result.output, /containerId abcdef/);
|
|
173
|
+
assert.deepStrictEqual(runner.calls[0], {
|
|
174
|
+
command: 'docker',
|
|
175
|
+
args: ['stop', 'docker-session'],
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
139
179
|
it('should send docker terminate through docker kill', () => {
|
|
140
180
|
const store = createStore();
|
|
141
181
|
store.save(
|