Skip to content

Commit 287c1ad

Browse files
committed
Add an execVerbose function for output separated into stdout and stderr
1 parent 27f858a commit 287c1ad

File tree

9 files changed

+283
-4
lines changed

9 files changed

+283
-4
lines changed

packages/testcontainers/src/container-runtime/clients/container/container-client.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,46 @@ import Dockerode, {
88
} from "dockerode";
99
import { Readable } from "stream";
1010
import { ContainerStatus, ExecOptions, ExecResult } from "./types";
11+
import { ExecVerboseResult } from "../../../types";
1112

1213
export interface ContainerClient {
1314
dockerode: Dockerode;
15+
1416
getById(id: string): Container;
17+
1518
fetchByLabel(
1619
labelName: string,
1720
labelValue: string,
1821
opts?: { status?: ContainerStatus[] }
1922
): Promise<Container | undefined>;
23+
2024
fetchArchive(container: Container, path: string): Promise<NodeJS.ReadableStream>;
25+
2126
putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise<void>;
27+
2228
list(): Promise<ContainerInfo[]>;
29+
2330
create(opts: ContainerCreateOptions): Promise<Container>;
31+
2432
start(container: Container): Promise<void>;
33+
2534
inspect(container: Container): Promise<ContainerInspectInfo>;
35+
2636
stop(container: Container, opts?: { timeout: number }): Promise<void>;
37+
2738
attach(container: Container): Promise<Readable>;
39+
2840
logs(container: Container, opts?: ContainerLogsOptions): Promise<Readable>;
41+
2942
exec(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecResult>;
43+
44+
execVerbose(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecVerboseResult>;
45+
3046
restart(container: Container, opts?: { timeout: number }): Promise<void>;
47+
3148
events(container: Container, eventNames: string[]): Promise<Readable>;
49+
3250
remove(container: Container, opts?: { removeVolumes: boolean }): Promise<void>;
51+
3352
connectToNetwork(container: Container, network: Network, networkAliases: string[]): Promise<void>;
3453
}

packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import Dockerode, {
99
} from "dockerode";
1010
import { PassThrough, Readable } from "stream";
1111
import { IncomingMessage } from "http";
12-
import { ContainerStatus, ExecOptions, ExecResult } from "./types";
12+
import { ContainerStatus, ExecOptions, ExecResult, ExecVerboseResult } from "./types";
1313
import byline from "byline";
1414
import { ContainerClient } from "./container-client";
1515
import { execLog, log, streamToString } from "../../../common";
@@ -235,6 +235,75 @@ export class DockerContainerClient implements ContainerClient {
235235
}
236236
}
237237

238+
async execVerbose(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecVerboseResult> {
239+
const execOptions: ExecCreateOptions = {
240+
Cmd: command,
241+
AttachStdout: true,
242+
AttachStderr: true,
243+
};
244+
245+
if (opts?.env !== undefined) {
246+
execOptions.Env = Object.entries(opts.env).map(([key, value]) => `${key}=${value}`);
247+
}
248+
if (opts?.workingDir !== undefined) {
249+
execOptions.WorkingDir = opts.workingDir;
250+
}
251+
if (opts?.user !== undefined) {
252+
execOptions.User = opts.user;
253+
}
254+
255+
const stdoutChunks: string[] = [];
256+
const stderrChunks: string[] = [];
257+
258+
try {
259+
if (opts?.log) {
260+
log.debug(`Execing container with command "${command.join(" ")}"...`, { containerId: container.id });
261+
}
262+
263+
const exec = await container.exec(execOptions);
264+
const stream = await exec.start({ stdin: true, Detach: false, Tty: false });
265+
266+
const stdoutStream = new PassThrough();
267+
const stderrStream = new PassThrough();
268+
269+
this.dockerode.modem.demuxStream(stream, stdoutStream, stderrStream);
270+
271+
const processStream = (stream: Readable, label: "stdout" | "stderr") => {
272+
stream.on("data", (chunk) => {
273+
if (label === "stdout") stdoutChunks.push(chunk);
274+
if (label === "stderr") stderrChunks.push(chunk);
275+
276+
if (opts?.log && execLog.enabled()) {
277+
execLog.trace(chunk.toString(), { containerId: container.id });
278+
}
279+
});
280+
};
281+
282+
processStream(stdoutStream, "stdout");
283+
processStream(stderrStream, "stderr");
284+
285+
await new Promise((res, rej) => {
286+
stream.on("end", res);
287+
stream.on("error", rej);
288+
});
289+
stream.destroy();
290+
291+
const inspectResult = await exec.inspect();
292+
const exitCode = inspectResult.ExitCode ?? -1;
293+
const stdout = stdoutChunks.join("");
294+
const stderr = stderrChunks.join("");
295+
if (opts?.log) {
296+
log.debug(`Execed container with command "${command.join(" ")}"...`, { containerId: container.id });
297+
}
298+
return { stdout, stderr, exitCode };
299+
} catch (err) {
300+
log.error(`Failed to exec container with command "${command.join(" ")}": ${err}: ${stderrChunks.join("")}`, {
301+
containerId: container.id,
302+
});
303+
throw err;
304+
}
305+
}
306+
238307
async restart(container: Container, opts?: { timeout: number }): Promise<void> {
239308
try {
240309
log.debug(`Restarting container...`, { containerId: container.id });

packages/testcontainers/src/container-runtime/clients/container/podman-container-client.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Container, ExecCreateOptions } from "dockerode";
2-
import { ExecOptions, ExecResult } from "./types";
2+
import { ExecOptions, ExecResult, ExecVerboseResult } from "./types";
33
import byline from "byline";
44
import { DockerContainerClient } from "./docker-container-client";
55
import { execLog, log } from "../../../common";
6+
import { PassThrough, Readable } from "stream";
67

78
export class PodmanContainerClient extends DockerContainerClient {
89
override async exec(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecResult> {
@@ -53,4 +54,79 @@ export class PodmanContainerClient extends DockerContainerClient {
5354
throw err;
5455
}
5556
}
57+
58+
override async execVerbose(
59+
container: Container,
60+
command: string[],
61+
opts?: Partial<ExecOptions>
62+
): Promise<ExecVerboseResult> {
63+
const execOptions: ExecCreateOptions = {
64+
Cmd: command,
65+
AttachStdout: true,
66+
AttachStderr: true,
67+
};
68+
69+
if (opts?.env !== undefined) {
70+
execOptions.Env = Object.entries(opts.env).map(([key, value]) => `${key}=${value}`);
71+
}
72+
if (opts?.workingDir !== undefined) {
73+
execOptions.WorkingDir = opts.workingDir;
74+
}
75+
if (opts?.user !== undefined) {
76+
execOptions.User = opts.user;
77+
}
78+
79+
const stdoutChunks: string[] = [];
80+
const stderrChunks: string[] = [];
81+
82+
try {
83+
if (opts?.log) {
84+
log.debug(`Execing container verbosely with command "${command.join(" ")}"...`, { containerId: container.id });
85+
}
86+
87+
const exec = await container.exec(execOptions);
88+
const stream = await exec.start({ stdin: true, Detach: false, Tty: false });
89+
90+
const stdoutStream = new PassThrough();
91+
const stderrStream = new PassThrough();
92+
93+
// Podman may use the same demuxing approach as Docker
94+
this.dockerode.modem.demuxStream(stream, stdoutStream, stderrStream);
95+
96+
const processStream = (stream: Readable, chunks: string[], label: "stdout" | "stderr") => {
97+
stream.on("data", (chunk) => {
98+
chunks.push(chunk.toString());
99+
if (opts?.log && execLog.enabled()) {
100+
execLog.trace(chunk.toString(), { containerId: container.id });
101+
}
102+
});
103+
};
104+
105+
processStream(stdoutStream, stdoutChunks, "stdout");
106+
processStream(stderrStream, stderrChunks, "stderr");
107+
108+
await new Promise((res, rej) => {
109+
stream.on("end", res);
110+
stream.on("error", rej);
111+
});
112+
stream.destroy();
113+
114+
const inspectResult = await exec.inspect();
115+
const exitCode = inspectResult.ExitCode ?? -1;
116+
117+
const stdout = stdoutChunks.join("");
118+
const stderr = stderrChunks.join("");
119+
120+
if (opts?.log) {
121+
log.debug(`ExecVerbose completed with command "${command.join(" ")}"`, { containerId: container.id });
122+
}
123+
124+
return { stdout, stderr, exitCode };
125+
} catch (err) {
126+
log.error(`Failed to exec container with command "${command.join(" ")}": ${err}: ${stderrChunks.join("")}`, {
127+
containerId: container.id,
128+
});
129+
throw err;
130+
}
131+
}
56132
}

packages/testcontainers/src/container-runtime/clients/container/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment;
44

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

7+
export type ExecVerboseResult = { stdout: string; stderr: string; exitCode: number };
8+
79
export const CONTAINER_STATUSES = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const;
810

911
export type ContainerStatus = (typeof CONTAINER_STATUSES)[number];

packages/testcontainers/src/generic-container/abstract-started-container.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";
2-
import { ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types";
2+
import {
3+
ContentToCopy,
4+
DirectoryToCopy,
5+
ExecOptions,
6+
ExecResult,
7+
ExecVerboseResult,
8+
FileToCopy,
9+
Labels,
10+
} from "../types";
311
import { Readable } from "stream";
412

513
export class AbstractStartedContainer implements StartedTestContainer {
@@ -83,6 +91,10 @@ export class AbstractStartedContainer implements StartedTestContainer {
8391
return this.startedTestContainer.exec(command, opts);
8492
}
8593

94+
public execVerbose(command: string | string[], opts?: Partial<ExecOptions>): Promise<ExecVerboseResult> {
95+
return this.startedTestContainer.execVerbose(command, opts);
96+
}
97+
8698
public logs(opts?: { since?: number; tail?: number }): Promise<Readable> {
8799
return this.startedTestContainer.logs(opts);
88100
}

packages/testcontainers/src/generic-container/generic-container.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,39 @@ describe("GenericContainer", () => {
8484
await container.stop();
8585
});
8686

87+
it("should execute a command on a running container with verbose output", async () => {
88+
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start();
89+
90+
const { stdout, exitCode } = await container.execVerbose(["echo", "hello", "world"]);
91+
92+
expect(exitCode).toBe(0);
93+
expect(stdout).toEqual(expect.stringContaining("hello world"));
94+
95+
await container.stop();
96+
});
97+
98+
it("should capture warnings from stderr with verbose output", async () => {
99+
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start();
100+
101+
const { stderr, exitCode } = await container.execVerbose(["sh", "-c", "echo 'Warning!' 1>&2"]);
102+
103+
expect(exitCode).toBe(0);
104+
expect(stderr).toEqual(expect.stringContaining("Warning!"));
105+
106+
await container.stop();
107+
});
108+
109+
it("should capture errors from stderr with verbose logging", async () => {
110+
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14").withExposedPorts(8080).start();
111+
112+
const { stderr, exitCode } = await container.execVerbose(["sh", "-c", "exit 1"]);
113+
114+
expect(exitCode).toBe(1);
115+
expect(stderr).toEqual(expect.stringContaining(""));
116+
117+
await container.stop();
118+
});
119+
87120
it("should set environment variables", async () => {
88121
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
89122
.withEnvironment({ customKey: "customValue" })

packages/testcontainers/src/generic-container/started-generic-container.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";
22
import Dockerode, { ContainerInspectInfo } from "dockerode";
3-
import { ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types";
3+
import {
4+
ContentToCopy,
5+
DirectoryToCopy,
6+
ExecOptions,
7+
ExecResult,
8+
ExecVerboseResult,
9+
FileToCopy,
10+
Labels,
11+
} from "../types";
412
import { Readable } from "stream";
513
import { StoppedGenericContainer } from "./stopped-generic-container";
614
import { WaitStrategy } from "../wait-strategies/wait-strategy";
@@ -181,6 +189,17 @@ export class StartedGenericContainer implements StartedTestContainer {
181189
return output;
182190
}
183191

192+
public async execVerbose(command: string | string[], opts?: Partial<ExecOptions>): Promise<ExecVerboseResult> {
193+
const commandArr = Array.isArray(command) ? command : command.split(" ");
194+
const commandStr = commandArr.join(" ");
195+
const client = await getContainerRuntimeClient();
196+
log.debug(`Executing command "${commandStr}"...`, { containerId: this.container.id });
197+
const output = await client.container.execVerbose(this.container, commandArr, opts);
198+
log.debug(`Executed command "${commandStr}"...`, { containerId: this.container.id });
199+
200+
return output;
201+
}
202+
184203
public async logs(opts?: { since?: number; tail?: number }): Promise<Readable> {
185204
const client = await getContainerRuntimeClient();
186205

0 commit comments

Comments
 (0)