diff --git a/Sources/ContainerCommands/Container/ContainerExec.swift b/Sources/ContainerCommands/Container/ContainerExec.swift index eef07aac..2573dbbc 100644 --- a/Sources/ContainerCommands/Container/ContainerExec.swift +++ b/Sources/ContainerCommands/Container/ContainerExec.swift @@ -34,6 +34,9 @@ extension Application { @OptionGroup var global: Flags.Global + @Flag(name: .shortAndLong, help: "Run the process and detach from it") + var detach = false + @Argument(help: "Container ID") var containerId: String @@ -71,11 +74,24 @@ extension Application { config.supplementalGroups.append(contentsOf: additionalGroups) do { - let io = try ProcessIO.create(tty: tty, interactive: stdin, detach: false) + let io = try ProcessIO.create(tty: tty, interactive: stdin, detach: self.detach) defer { try? io.close() } + let process = try await container.createProcess( + id: UUID().uuidString.lowercased(), + configuration: config, + stdio: io.stdio + ) + + if self.detach { + try await process.start() + try io.closeAfterStart() + print(containerId) + return + } + if !self.processFlags.tty { var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) handler.start { @@ -84,12 +100,6 @@ extension Application { } } - let process = try await container.createProcess( - id: UUID().uuidString.lowercased(), - configuration: config, - stdio: io.stdio - ) - exitCode = try await io.handleProcess(process: process, log: log) } catch { if error is ContainerizationError { diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift b/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift index c2a0a140..c7c9d55f 100644 --- a/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift +++ b/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift @@ -39,4 +39,72 @@ class TestCLIExecCommand: CLITest { return } } + + @Test func testExecDetach() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + + // Run a long-running process in detached mode + let output = try doExec(name: name, cmd: ["sh", "-c", "touch /tmp/detach_test_marker"], detach: true) + let containerIdOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) + try #require(containerIdOutput == name, "exec --detach should print the container ID") + + // Verify the detached process is running by checking if we can still exec commands + var lsActual = try doExec(name: name, cmd: ["ls", "/"]) + lsActual = lsActual.trimmingCharacters(in: .whitespacesAndNewlines) + try #require(lsActual.contains("tmp"), "container should still be running and accepting exec commands") + + // Retry loop to check if the marker file was created by the detached process + var markerFound = false + for _ in 0..<3 { + let (_, _, status) = try run(arguments: [ + "exec", + name, + "test", "-f", "/tmp/detach_test_marker", + ]) + if status == 0 { + markerFound = true + break + } + sleep(1) + } + try #require(markerFound, "marker file should be created by detached process within 3 seconds") + + try doStop(name: name) + } catch { + Issue.record("failed to exec with detach in container \(error)") + return + } + } + + @Test func testExecDetachProcessRunning() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + + // Run a long-running process in detached mode + let output = try doExec(name: name, cmd: ["sleep", "10"], detach: true) + let containerIdOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) + try #require(containerIdOutput == name, "exec --detach should print the container ID") + + // Immediately check if the process is running using ps + var psOutput = try doExec(name: name, cmd: ["ps", "aux"]) + psOutput = psOutput.trimmingCharacters(in: .whitespacesAndNewlines) + try #require(psOutput.contains("sleep 10"), "detached process 'sleep 10' should be visible in ps output") + + try doStop(name: name) + } catch { + Issue.record("failed to verify detached process is running \(error)") + return + } + } } diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index 42fd7dc2..e982bf0c 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -226,11 +226,14 @@ class CLITest { } } - func doExec(name: String, cmd: [String]) throws -> String { + func doExec(name: String, cmd: [String], detach: Bool = false) throws -> String { var execArgs = [ - "exec", - name, + "exec" ] + if detach { + execArgs.append("-d") + } + execArgs.append(name) execArgs.append(contentsOf: cmd) let (resp, error, status) = try run(arguments: execArgs) if status != 0 { diff --git a/docs/command-reference.md b/docs/command-reference.md index 2f1b2b48..f30284b6 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -313,7 +313,7 @@ Executes a command inside a running container. It uses the same process flags as **Usage** ```bash -container exec [--env ...] [--env-file ...] [--gid ] [--interactive] [--tty] [--user ] [--uid ] [--workdir ] [--debug] ... +container exec [--detach] [--env ...] [--env-file ...] [--gid ] [--interactive] [--tty] [--user ] [--uid ] [--workdir ] [--debug] ... ``` **Arguments** @@ -321,6 +321,10 @@ container exec [--env ...] [--env-file ...] [--gid ] [--in * ``: Container ID * ``: New process arguments +**Options** + +* `-d, --detach`: Run the process and detach from it + **Process Options** * `-e, --env `: Set environment variables (format: key=value)