Skip to content

Commit

Permalink
Add stdout and stderr fields to container exec result (#874)
Browse files Browse the repository at this point in the history
  • Loading branch information
chungjung-d authored Nov 25, 2024
1 parent 27f858a commit 686a8c6
Show file tree
Hide file tree
Showing 9 changed files with 91 additions and 84 deletions.
11 changes: 7 additions & 4 deletions docs/features/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: {
Expand All @@ -538,6 +539,8 @@ const { output, exitCode } = await container.exec(["echo", "hello", "world"], {
});
```



## Streaming logs

Logs can be consumed either from a started container:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -105,9 +104,7 @@ async function initStrategy(strategy: ContainerRuntimeClientStrategy): Promise<C
const hostIps = await lookupHostIps(host);

log.trace("Initialising clients...");
const containerClient = result.uri.includes("podman.sock")
? new PodmanContainerClient(dockerode)
: new DockerContainerClient(dockerode);
const containerClient = new DockerContainerClient(dockerode);
const imageClient = new DockerImageClient(dockerode, indexServerAddress);
const networkClient = new DockerNetworkClient(dockerode);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import { ContainerStatus, ExecOptions, ExecResult } from "./types";

export interface ContainerClient {
dockerode: Dockerode;

getById(id: string): Container;

fetchByLabel(
labelName: string,
labelValue: string,
opts?: { status?: ContainerStatus[] }
): Promise<Container | undefined>;

fetchArchive(container: Container, path: string): Promise<NodeJS.ReadableStream>;
putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise<void>;
list(): Promise<ContainerInfo[]>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -201,34 +200,55 @@ 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);
});
stream.destroy();

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;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,32 +42,38 @@ 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();
});

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();
});

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();
});
Expand All @@ -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();
});
Expand Down
1 change: 1 addition & 0 deletions packages/testcontainers/src/test-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,6 @@ export interface StartedTestContainer {

export interface StoppedTestContainer {
getId(): string;

copyArchiveFromContainer(path: string): Promise<NodeJS.ReadableStream>;
}
2 changes: 1 addition & 1 deletion packages/testcontainers/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down

0 comments on commit 686a8c6

Please sign in to comment.