Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading