From 686a8c68059ffae19fa761460e909f30862e601c Mon Sep 17 00:00:00 2001 From: chungjung-d <63407866+chungjung-d@users.noreply.github.com> Date: Tue, 26 Nov 2024 05:48:04 +0900 Subject: [PATCH] Add `stdout` and `stderr` fields to container exec result (#874) --- docs/features/containers.md | 11 ++-- .../src/container-runtime/clients/client.ts | 5 +- .../clients/container/container-client.ts | 3 + .../container/docker-container-client.ts | 40 +++++++++---- .../container/podman-container-client.ts | 56 ------------------- .../clients/container/types.ts | 2 +- .../generic-container.test.ts | 55 +++++++++++++++--- packages/testcontainers/src/test-container.ts | 1 + packages/testcontainers/src/types.ts | 2 +- 9 files changed, 91 insertions(+), 84 deletions(-) delete mode 100644 packages/testcontainers/src/container-runtime/clients/container/podman-container-client.ts diff --git a/docs/features/containers.md b/docs/features/containers.md index 43bbbb0ce..1f5501e3c 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -503,15 +503,16 @@ const container = await new GenericContainer("alpine") ## Running commands -To run a command inside an already started container use the `exec` method. The command will be run in the container's -working directory, returning the command output and exit code: +To run a command inside an already started container, use the exec method. +The command will be run in the container's working directory, +returning the combined output (`output`), standard output (`stdout`), standard error (`stderr`), and exit code (`exitCode`). ```javascript const container = await new GenericContainer("alpine") .withCommand(["sleep", "infinity"]) .start(); -const { output, exitCode } = await container.exec(["echo", "hello", "world"]); +const { output, stdout, stderr, exitCode } = await container.exec(["echo", "hello", "world"]); ``` The following options can be provided to modify the command execution: @@ -528,7 +529,7 @@ const container = await new GenericContainer("alpine") .withCommand(["sleep", "infinity"]) .start(); -const { output, exitCode } = await container.exec(["echo", "hello", "world"], { +const { output, stdout, stderr, exitCode } = await container.exec(["echo", "hello", "world"], { workingDir: "/app/src/", user: "1000:1000", env: { @@ -538,6 +539,8 @@ const { output, exitCode } = await container.exec(["echo", "hello", "world"], { }); ``` + + ## Streaming logs Logs can be consumed either from a started container: diff --git a/packages/testcontainers/src/container-runtime/clients/client.ts b/packages/testcontainers/src/container-runtime/clients/client.ts index 898f14664..70af6f61c 100644 --- a/packages/testcontainers/src/container-runtime/clients/client.ts +++ b/packages/testcontainers/src/container-runtime/clients/client.ts @@ -12,7 +12,6 @@ import { ComposeInfo, ContainerRuntimeInfo, Info, NodeInfo } from "./types"; import Dockerode, { DockerOptions } from "dockerode"; import { getRemoteContainerRuntimeSocketPath } from "../utils/remote-container-runtime-socket-path"; import { resolveHost } from "../utils/resolve-host"; -import { PodmanContainerClient } from "./container/podman-container-client"; import { DockerContainerClient } from "./container/docker-container-client"; import { DockerImageClient } from "./image/docker-image-client"; import { DockerNetworkClient } from "./network/docker-network-client"; @@ -105,9 +104,7 @@ async function initStrategy(strategy: ContainerRuntimeClientStrategy): Promise; + fetchArchive(container: Container, path: string): Promise; putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise; list(): Promise; diff --git a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts index 226e58b73..7367b5d18 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts @@ -10,7 +10,6 @@ import Dockerode, { import { PassThrough, Readable } from "stream"; import { IncomingMessage } from "http"; import { ContainerStatus, ExecOptions, ExecResult } from "./types"; -import byline from "byline"; import { ContainerClient } from "./container-client"; import { execLog, log, streamToString } from "../../../common"; @@ -201,20 +200,38 @@ export class DockerContainerClient implements ContainerClient { execOptions.User = opts.user; } - const chunks: string[] = []; + const outputChunks: string[] = []; + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + try { if (opts?.log) { log.debug(`Execing container with command "${command.join(" ")}"...`, { containerId: container.id }); } const exec = await container.exec(execOptions); - const stream = await exec.start({ stdin: true, Detach: false, Tty: true }); - if (opts?.log && execLog.enabled()) { - byline(stream).on("data", (line) => execLog.trace(line, { containerId: container.id })); - } + const stream = await exec.start({ stdin: true, Detach: false, Tty: false }); + + const stdoutStream = new PassThrough(); + const stderrStream = new PassThrough(); + + this.dockerode.modem.demuxStream(stream, stdoutStream, stderrStream); + + const processStream = (stream: Readable, chunks: string[]) => { + stream.on("data", (chunk) => { + chunks.push(chunk.toString()); + outputChunks.push(chunk.toString()); + + if (opts?.log && execLog.enabled()) { + execLog.trace(chunk.toString(), { containerId: container.id }); + } + }); + }; + + processStream(stdoutStream, stdoutChunks); + processStream(stderrStream, stderrChunks); await new Promise((res, rej) => { - stream.on("data", (chunk) => chunks.push(chunk)); stream.on("end", res); stream.on("error", rej); }); @@ -222,13 +239,16 @@ export class DockerContainerClient implements ContainerClient { const inspectResult = await exec.inspect(); const exitCode = inspectResult.ExitCode ?? -1; - const output = chunks.join(""); + const output = outputChunks.join(""); + const stdout = stdoutChunks.join(""); + const stderr = stderrChunks.join(""); + if (opts?.log) { log.debug(`Execed container with command "${command.join(" ")}"...`, { containerId: container.id }); } - return { output, exitCode }; + return { output, stdout, stderr, exitCode }; } catch (err) { - log.error(`Failed to exec container with command "${command.join(" ")}": ${err}: ${chunks.join("")}`, { + log.error(`Failed to exec container with command "${command.join(" ")}": ${err}: ${outputChunks.join("")}`, { containerId: container.id, }); throw err; diff --git a/packages/testcontainers/src/container-runtime/clients/container/podman-container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/podman-container-client.ts deleted file mode 100644 index 20a0fab4f..000000000 --- a/packages/testcontainers/src/container-runtime/clients/container/podman-container-client.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Container, ExecCreateOptions } from "dockerode"; -import { ExecOptions, ExecResult } from "./types"; -import byline from "byline"; -import { DockerContainerClient } from "./docker-container-client"; -import { execLog, log } from "../../../common"; - -export class PodmanContainerClient extends DockerContainerClient { - override async exec(container: Container, command: string[], opts?: Partial): Promise { - const execOptions: ExecCreateOptions = { - Cmd: command, - AttachStdout: true, - AttachStderr: true, - }; - - if (opts?.env !== undefined) { - execOptions.Env = Object.entries(opts.env).map(([key, value]) => `${key}=${value}`); - } - if (opts?.workingDir !== undefined) { - execOptions.WorkingDir = opts.workingDir; - } - if (opts?.user !== undefined) { - execOptions.User = opts.user; - } - - const chunks: string[] = []; - try { - if (opts?.log) { - log.debug(`Execing container with command "${command.join(" ")}"...`, { containerId: container.id }); - } - - const exec = await container.exec(execOptions); - const stream = await this.demuxStream(container.id, await exec.start({ stdin: true, Detach: false, Tty: true })); - if (opts?.log && execLog.enabled()) { - byline(stream).on("data", (line) => execLog.trace(line, { containerId: container.id })); - } - - await new Promise((res, rej) => { - stream.on("data", (chunk) => chunks.push(chunk)); - stream.on("end", res); - stream.on("error", rej); - }); - stream.destroy(); - - const inspectResult = await exec.inspect(); - const exitCode = inspectResult.ExitCode ?? -1; - const output = chunks.join(""); - - return { output, exitCode }; - } catch (err) { - log.error(`Failed to exec container with command "${command.join(" ")}": ${err}: ${chunks.join("")}`, { - containerId: container.id, - }); - throw err; - } - } -} diff --git a/packages/testcontainers/src/container-runtime/clients/container/types.ts b/packages/testcontainers/src/container-runtime/clients/container/types.ts index 7062d54ce..c076a71f5 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/types.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/types.ts @@ -2,7 +2,7 @@ export type Environment = { [key in string]: string }; export type ExecOptions = { workingDir: string; user: string; env: Environment; log: boolean }; -export type ExecResult = { output: string; exitCode: number }; +export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number }; export const CONTAINER_STATUSES = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const; diff --git a/packages/testcontainers/src/generic-container/generic-container.test.ts b/packages/testcontainers/src/generic-container/generic-container.test.ts index 6484c0ef6..9eff27f52 100644 --- a/packages/testcontainers/src/generic-container/generic-container.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container.test.ts @@ -42,10 +42,12 @@ describe("GenericContainer", () => { it("should execute a command on a running container", async () => { const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start(); - const { output, exitCode } = await container.exec(["echo", "hello", "world"]); + const { output, stdout, stderr, exitCode } = await container.exec(["echo", "hello", "world"]); expect(exitCode).toBe(0); - expect(output).toEqual(expect.stringContaining("hello world")); + expect(stdout).toEqual(expect.stringContaining("hello world")); + expect(stderr).toBe(""); + expect(output).toEqual(stdout); await container.stop(); }); @@ -53,10 +55,12 @@ describe("GenericContainer", () => { it("should execute a command in a different working directory", async () => { const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start(); - const { output, exitCode } = await container.exec(["pwd"], { workingDir: "/var/log" }); + const { output, stdout, stderr, exitCode } = await container.exec(["pwd"], { workingDir: "/var/log" }); expect(exitCode).toBe(0); - expect(output).toEqual(expect.stringContaining("/var/log")); + expect(stdout).toEqual(expect.stringContaining("/var/log")); + expect(stderr).toBe(""); + expect(output).toEqual(stdout); await container.stop(); }); @@ -64,10 +68,12 @@ describe("GenericContainer", () => { it("should execute a command with custom environment variables", async () => { const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start(); - const { output, exitCode } = await container.exec(["env"], { env: { TEST_ENV: "test" } }); + const { output, stdout, stderr, exitCode } = await container.exec(["env"], { env: { TEST_ENV: "test" } }); expect(exitCode).toBe(0); - expect(output).toEqual(expect.stringContaining("TEST_ENV=test")); + expect(stdout).toEqual(expect.stringContaining("TEST_ENV=test")); + expect(stderr).toBe(""); + expect(output).toEqual(stdout); await container.stop(); }); @@ -76,10 +82,43 @@ describe("GenericContainer", () => { // By default, node:alpine runs as root const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start(); - const { output, exitCode } = await container.exec("whoami", { user: "node" }); + const { output, stdout, stderr, exitCode } = await container.exec(["whoami"], { user: "node" }); expect(exitCode).toBe(0); - expect(output).toEqual(expect.stringContaining("node")); + expect(stdout).toEqual(expect.stringContaining("node")); + expect(stderr).toBe(""); + expect(output).toEqual(stdout); + + await container.stop(); + }); + + it("should capture stderr when a command fails", async () => { + const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start(); + + const { output, stdout, stderr, exitCode } = await container.exec(["ls", "/nonexistent/path"]); + + expect(exitCode).not.toBe(0); + expect(stdout).toBe(""); + expect(stderr).toEqual(expect.stringContaining("No such file or directory")); + expect(output).toEqual(stderr); + + await container.stop(); + }); + + it("should capture stdout and stderr in the correct order", async () => { + const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start(); + + // The command first writes to stdout and then tries to access a nonexistent file (stderr) + const { output, stdout, stderr, exitCode } = await container.exec([ + "sh", + "-c", + "echo 'This is stdout'; ls /nonexistent/path", + ]); + + expect(exitCode).not.toBe(0); // The command should fail due to the ls error + expect(stdout).toEqual(expect.stringContaining("This is stdout")); + expect(stderr).toEqual(expect.stringContaining("No such file or directory")); + expect(output).toMatch(/This is stdout[\s\S]*No such file or directory/); await container.stop(); }); diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index b2410024c..056921ac8 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -81,5 +81,6 @@ export interface StartedTestContainer { export interface StoppedTestContainer { getId(): string; + copyArchiveFromContainer(path: string): Promise; } diff --git a/packages/testcontainers/src/types.ts b/packages/testcontainers/src/types.ts index 2840dd4f0..ad8eb865e 100644 --- a/packages/testcontainers/src/types.ts +++ b/packages/testcontainers/src/types.ts @@ -83,7 +83,7 @@ export type BuildArgs = { [key in string]: string }; export type ExecOptions = { workingDir: string; user: string; env: Environment }; -export type ExecResult = { output: string; exitCode: number }; +export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number }; export type HealthCheckStatus = "none" | "starting" | "unhealthy" | "healthy";