Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
20 changes: 20 additions & 0 deletions docs/features/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,26 @@ const { output, exitCode } = await container.exec(["echo", "hello", "world"], {
}
});
```
To handle cases where you need separate outputs for `stdout` and `stderr`, you can use the `execVerbose` method.
It functions similarly to `exec`, but provides detailed output including `stdout`, `stderr`, and `exitCode`.

```javascript
const container = await new GenericContainer("alpine")
.withCommand(["sleep", "infinity"])
.start();

const { stdout, stderr, exitCode } = await container.execVerbose(["echo", "hello", "world"], {
workingDir: "/app/src/",
user: "1000:1000",
env: {
"VAR1": "enabled",
"VAR2": "/app/debug.log",
}
});
```
Use `execVerbose` when you require more granular control over command outputs,
while retaining similar options and functionality as `exec`.


## Streaming logs

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,46 @@ import Dockerode, {
} from "dockerode";
import { Readable } from "stream";
import { ContainerStatus, ExecOptions, ExecResult } from "./types";
import { ExecVerboseResult } 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[]>;

create(opts: ContainerCreateOptions): Promise<Container>;

start(container: Container): Promise<void>;

inspect(container: Container): Promise<ContainerInspectInfo>;

stop(container: Container, opts?: { timeout: number }): Promise<void>;

attach(container: Container): Promise<Readable>;

logs(container: Container, opts?: ContainerLogsOptions): Promise<Readable>;

exec(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecResult>;

execVerbose(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecVerboseResult>;

restart(container: Container, opts?: { timeout: number }): Promise<void>;

events(container: Container, eventNames: string[]): Promise<Readable>;

remove(container: Container, opts?: { removeVolumes: boolean }): Promise<void>;

connectToNetwork(container: Container, network: Network, networkAliases: string[]): Promise<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Dockerode, {
} from "dockerode";
import { PassThrough, Readable } from "stream";
import { IncomingMessage } from "http";
import { ContainerStatus, ExecOptions, ExecResult } from "./types";
import { ContainerStatus, ExecOptions, ExecResult, ExecVerboseResult } from "./types";
import byline from "byline";
import { ContainerClient } from "./container-client";
import { execLog, log, streamToString } from "../../../common";
Expand Down Expand Up @@ -235,6 +235,75 @@ export class DockerContainerClient implements ContainerClient {
}
}

async execVerbose(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecVerboseResult> {
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 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: false });

const stdoutStream = new PassThrough();
const stderrStream = new PassThrough();

this.dockerode.modem.demuxStream(stream, stdoutStream, stderrStream);

const processStream = (stream: Readable, label: "stdout" | "stderr") => {
stream.on("data", (chunk) => {
if (label === "stdout") stdoutChunks.push(chunk);
if (label === "stderr") stderrChunks.push(chunk);

if (opts?.log && execLog.enabled()) {
execLog.trace(chunk.toString(), { containerId: container.id });
}
});
};

processStream(stdoutStream, "stdout");
processStream(stderrStream, "stderr");

await new Promise((res, rej) => {
stream.on("end", res);
stream.on("error", rej);
});
stream.destroy();

const inspectResult = await exec.inspect();
const exitCode = inspectResult.ExitCode ?? -1;
const stdout = stdoutChunks.join("");
const stderr = stderrChunks.join("");
if (opts?.log) {
log.debug(`Execed container with command "${command.join(" ")}"...`, { containerId: container.id });
}
return { stdout, stderr, exitCode };
} catch (err) {
log.error(`Failed to exec container with command "${command.join(" ")}": ${err}: ${stderrChunks.join("")}`, {
containerId: container.id,
});
throw err;
}
}

async restart(container: Container, opts?: { timeout: number }): Promise<void> {
try {
log.debug(`Restarting container...`, { containerId: container.id });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Container, ExecCreateOptions } from "dockerode";
import { ExecOptions, ExecResult } from "./types";
import { ExecOptions, ExecResult, ExecVerboseResult } 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 Down Expand Up @@ -53,4 +54,79 @@
throw err;
}
}

override async execVerbose(
container: Container,
command: string[],
opts?: Partial<ExecOptions>
): Promise<ExecVerboseResult> {
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 stdoutChunks: string[] = [];
const stderrChunks: string[] = [];

try {
if (opts?.log) {
log.debug(`Execing container verbosely with command "${command.join(" ")}"...`, { containerId: container.id });
}

const exec = await container.exec(execOptions);
const stream = await exec.start({ stdin: true, Detach: false, Tty: false });

const stdoutStream = new PassThrough();
const stderrStream = new PassThrough();

// Podman may use the same demuxing approach as Docker
this.dockerode.modem.demuxStream(stream, stdoutStream, stderrStream);

const processStream = (stream: Readable, chunks: string[], label: "stdout" | "stderr") => {

Check warning on line 96 in packages/testcontainers/src/container-runtime/clients/container/podman-container-client.ts

View workflow job for this annotation

GitHub Actions / lint

'label' is defined but never used
stream.on("data", (chunk) => {
chunks.push(chunk.toString());
if (opts?.log && execLog.enabled()) {
execLog.trace(chunk.toString(), { containerId: container.id });
}
});
};

processStream(stdoutStream, stdoutChunks, "stdout");
processStream(stderrStream, stderrChunks, "stderr");

await new Promise((res, rej) => {
stream.on("end", res);
stream.on("error", rej);
});
stream.destroy();

const inspectResult = await exec.inspect();
const exitCode = inspectResult.ExitCode ?? -1;

const stdout = stdoutChunks.join("");
const stderr = stderrChunks.join("");

if (opts?.log) {
log.debug(`ExecVerbose completed with command "${command.join(" ")}"`, { containerId: container.id });
}

return { stdout, stderr, exitCode };
} catch (err) {
log.error(`Failed to exec container with command "${command.join(" ")}": ${err}: ${stderrChunks.join("")}`, {
containerId: container.id,
});
throw err;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment;

export type ExecResult = { output: string; exitCode: number };

export type ExecVerboseResult = { stdout: string; stderr: string; exitCode: number };

export const CONTAINER_STATUSES = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const;

export type ContainerStatus = (typeof CONTAINER_STATUSES)[number];
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 @@ -84,6 +84,39 @@ describe("GenericContainer", () => {
await container.stop();
});

it("should execute a command on a running container with verbose output", async () => {
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start();

const { stdout, exitCode } = await container.execVerbose(["echo", "hello", "world"]);

expect(exitCode).toBe(0);
expect(stdout).toEqual(expect.stringContaining("hello world"));

await container.stop();
});

it("should capture warnings from stderr with verbose output", async () => {
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start();

const { stderr, exitCode } = await container.execVerbose(["sh", "-c", "echo 'Warning!' 1>&2"]);

expect(exitCode).toBe(0);
expect(stderr).toEqual(expect.stringContaining("Warning!"));

await container.stop();
});

it("should capture errors from stderr with verbose logging", async () => {
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start();

const { stderr, exitCode } = await container.execVerbose(["sh", "-c", "exit 1"]);

expect(exitCode).toBe(1);
expect(stderr).toEqual(expect.stringContaining(""));

await container.stop();
});

it("should set environment variables", async () => {
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withEnvironment({ customKey: "customValue" })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
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,
ExecVerboseResult,
FileToCopy,
Labels,
} from "../types";
import { Readable } from "stream";
import { StoppedGenericContainer } from "./stopped-generic-container";
import { WaitStrategy } from "../wait-strategies/wait-strategy";
Expand Down Expand Up @@ -181,6 +189,17 @@ export class StartedGenericContainer implements StartedTestContainer {
return output;
}

public async execVerbose(command: string | string[], opts?: Partial<ExecOptions>): Promise<ExecVerboseResult> {
const commandArr = Array.isArray(command) ? command : command.split(" ");
const commandStr = commandArr.join(" ");
const client = await getContainerRuntimeClient();
log.debug(`Executing command "${commandStr}"...`, { containerId: this.container.id });
const output = await client.container.execVerbose(this.container, commandArr, opts);
log.debug(`Executed command "${commandStr}"...`, { containerId: this.container.id });

return output;
}

public async logs(opts?: { since?: number; tail?: number }): Promise<Readable> {
const client = await getContainerRuntimeClient();

Expand Down
Loading
Loading