Skip to content

Commit

Permalink
Add support for container exec options
Browse files Browse the repository at this point in the history
  • Loading branch information
LNSD committed Nov 22, 2023
1 parent ec59ed8 commit bb25fe4
Show file tree
Hide file tree
Showing 10 changed files with 122 additions and 29 deletions.
27 changes: 27 additions & 0 deletions docs/features/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@ 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:

```javascript
const container = await new GenericContainer("alpine")
.withCommand(["sleep", "infinity"])
Expand All @@ -503,6 +506,30 @@ const container = await new GenericContainer("alpine")
const { output, exitCode } = await container.exec(["echo", "hello", "world"]);
```

The following options can be provided to modify the command execution:

1. **`user`:** The user, and optionally, group to run the exec process inside the container. Format is one of: `user`, `user:group`, `uid`, or `uid:gid`.

2. **`workingDir`:** The working directory for the exec process inside the container.

3. **`env`:** A map of environment variables to set inside the container.


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

const { output, exitCode } = await container.exec(["echo", "hello", "world"], {
workingDir: "/app/src/",
user: "1000:1000",
env: {
"VAR1": "enabled",
"VAR2": "/app/debug.log",
}
});
````

## 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 @@ -7,7 +7,7 @@ import Dockerode, {
Network,
} from "dockerode";
import { Readable } from "stream";
import { ExecResult } from "./types";
import { ExecOptions, ExecResult } from "./types";

export interface ContainerClient {
dockerode: Dockerode;
Expand All @@ -22,7 +22,7 @@ export interface ContainerClient {
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?: { log: boolean }): Promise<ExecResult>;
exec(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecResult>;
restart(container: Container, opts?: { timeout: number }): Promise<void>;
remove(container: Container, opts?: { removeVolumes: boolean }): Promise<void>;
connectToNetwork(container: Container, network: Network, networkAliases: string[]): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import Dockerode, {
ContainerInfo,
ContainerInspectInfo,
ContainerLogsOptions,
ExecCreateOptions,
Network,
} from "dockerode";
import { PassThrough, Readable } from "stream";
import { IncomingMessage } from "http";
import { ExecResult } from "./types";
import { ExecOptions, ExecResult } from "./types";
import byline from "byline";
import { ContainerClient } from "./container-client";
import { log, execLog, streamToString } from "../../../common";
Expand Down Expand Up @@ -173,21 +174,31 @@ export class DockerContainerClient implements ContainerClient {
}
}

async exec(container: Container, command: string[], opts?: { log: boolean }): Promise<ExecResult> {
const chunks: string[] = [];
async exec(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecResult> {
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 chunks: string[] = [];
try {
if (opts?.log) {
log.debug(`Execing container with command "${command.join(" ")}"...`, { containerId: container.id });
}
const exec = await container.exec({
Cmd: command,
AttachStdout: true,
AttachStderr: true,
});

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 }));
}
Expand All @@ -200,7 +211,7 @@ export class DockerContainerClient implements ContainerClient {
stream.destroy();

const inspectResult = await exec.inspect();
const exitCode = inspectResult.ExitCode === null ? -1 : inspectResult.ExitCode;
const exitCode = inspectResult.ExitCode ?? -1;
const output = chunks.join("");
if (opts?.log) {
log.debug(`Execed container with command "${command.join(" ")}"...`, { containerId: container.id });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import { Container } from "dockerode";
import { ExecResult } from "./types";
import { Container, ExecCreateOptions } from "dockerode";
import { ExecOptions, ExecResult } from "./types";
import byline from "byline";
import { DockerContainerClient } from "./docker-container-client";
import { execLog, log } from "../../../common";

export class PodmanContainerClient extends DockerContainerClient {
override async exec(container: Container, command: string[], opts?: { log: boolean }): Promise<ExecResult> {
const chunks: string[] = [];
override async exec(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecResult> {
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 chunks: string[] = [];
try {
const exec = await container.exec({
Cmd: command,
AttachStdout: true,
AttachStderr: true,
});
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 }));
Expand All @@ -28,7 +42,7 @@ export class PodmanContainerClient extends DockerContainerClient {
stream.destroy();

const inspectResult = await exec.inspect();
const exitCode = inspectResult.ExitCode === null ? -1 : inspectResult.ExitCode;
const exitCode = inspectResult.ExitCode ?? -1;
const output = chunks.join("");

return { output, exitCode };
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
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 };
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";
import { ContentToCopy, DirectoryToCopy, ExecResult, FileToCopy, Labels } from "../types";
import { ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types";
import { Readable } from "stream";

export class AbstractStartedContainer implements StartedTestContainer {
Expand Down Expand Up @@ -79,8 +79,8 @@ export class AbstractStartedContainer implements StartedTestContainer {
return this.startedTestContainer.copyArchiveFromContainer(path);
}

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

public logs(): Promise<Readable> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,40 @@ describe("GenericContainer", () => {
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" });

expect(exitCode).toBe(0);
expect(output).toEqual(expect.stringContaining("/var/log"));

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

expect(exitCode).toBe(0);
expect(output).toEqual(expect.stringContaining("TEST_ENV=test"));

await container.stop();
});

it("should execute a command with a different user", async () => {
// 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" });

expect(exitCode).toBe(0);
expect(output).toEqual(expect.stringContaining("node"));

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,6 @@
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";
import Dockerode, { ContainerInspectInfo } from "dockerode";
import { ContentToCopy, DirectoryToCopy, 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 Expand Up @@ -170,12 +170,12 @@ export class StartedGenericContainer implements StartedTestContainer {
return stream;
}

public async exec(command: string | string[]): Promise<ExecResult> {
public async exec(command: string | string[], opts?: Partial<ExecOptions>): Promise<ExecResult> {
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.exec(this.container, commandArr);
const output = await client.container.exec(this.container, commandArr, opts);
log.debug(`Executed command "${commandStr}"...`, { containerId: this.container.id });

return output;
Expand Down
3 changes: 2 additions & 1 deletion packages/testcontainers/src/test-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ContentToCopy,
DirectoryToCopy,
Environment,
ExecOptions,
ExecResult,
ExtraHost,
FileToCopy,
Expand Down Expand Up @@ -73,7 +74,7 @@ export interface StartedTestContainer {
copyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): Promise<void>;
copyFilesToContainer(filesToCopy: FileToCopy[]): Promise<void>;
copyContentToContainer(contentsToCopy: ContentToCopy[]): Promise<void>;
exec(command: string | string[]): Promise<ExecResult>;
exec(command: string | string[], opts?: Partial<ExecOptions>): Promise<ExecResult>;
logs(): Promise<Readable>;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/testcontainers/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export type RegistryConfig = {

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 HealthCheckStatus = "none" | "starting" | "unhealthy" | "healthy";
Expand Down

0 comments on commit bb25fe4

Please sign in to comment.