Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 @@ -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 @@ -201,34 +201,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}: ${stderrChunks.join("")}`, {
containerId: container.id,
});
throw err;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ExecOptions, ExecResult } from "./types";
import byline from "byline";
import { DockerContainerClient } from "./docker-container-client";
import { execLog, log } from "../../../common";
import { PassThrough, Readable } from "stream";

export class PodmanContainerClient extends DockerContainerClient {
override async exec(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecResult> {
Expand All @@ -22,32 +23,52 @@ export class PodmanContainerClient extends DockerContainerClient {
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 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 }));
}

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) => {
outputChunks.push(chunk.toString());
chunks.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("");

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}: ${stderrChunks.join("")}`, {
containerId: container.id,
});
throw err;
Expand Down
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
@@ -1,5 +1,13 @@
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";
import { ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types";
import {
ContentToCopy,
DirectoryToCopy,
ExecOptions,
ExecResult,
ExecVerboseResult,
FileToCopy,
Labels,
} from "../types";
import { Readable } from "stream";

export class AbstractStartedContainer implements StartedTestContainer {
Expand Down Expand Up @@ -83,6 +91,10 @@ export class AbstractStartedContainer implements StartedTestContainer {
return this.startedTestContainer.exec(command, opts);
}

public execVerbose(command: string | string[], opts?: Partial<ExecOptions>): Promise<ExecVerboseResult> {
return this.startedTestContainer.execVerbose(command, opts);
}

public logs(opts?: { since?: number; tail?: number }): Promise<Readable> {
return this.startedTestContainer.logs(opts);
}
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";
import Dockerode, { ContainerInspectInfo } from "dockerode";
import { ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types";
import {
ContentToCopy,
DirectoryToCopy,
ExecOptions,
ExecResult,
FileToCopy,
Labels,
} from "../types";
import { Readable } from "stream";
import { StoppedGenericContainer } from "./stopped-generic-container";
import { WaitStrategy } from "../wait-strategies/wait-strategy";
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