Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for setting user, workingDir and env when executing a command in a container #668

Merged
merged 2 commits into from
Nov 23, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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>;
mdelapenya marked this conversation as resolved.
Show resolved Hide resolved
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;
}
mdelapenya marked this conversation as resolved.
Show resolved Hide resolved

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;
mdelapenya marked this conversation as resolved.
Show resolved Hide resolved
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;
mdelapenya marked this conversation as resolved.
Show resolved Hide resolved
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" });
mdelapenya marked this conversation as resolved.
Show resolved Hide resolved

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
2 changes: 1 addition & 1 deletion packages/testcontainers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export { Network, StartedNetwork, StoppedNetwork } from "./network/network";
export { Wait } from "./wait-strategies/wait";
export { StartupCheckStrategy, StartupStatus } from "./wait-strategies/startup-check-strategy";
export { PullPolicy, ImagePullPolicy } from "./utils/pull-policy";
export { InspectResult, Content, ExecResult } from "./types";
export { InspectResult, Content, ExecOptions, ExecResult } from "./types";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done ✔️ 😁


export { AbstractStartedContainer } from "./generic-container/abstract-started-container";
export { AbstractStoppedContainer } from "./generic-container/abstract-stopped-container";
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 };
mdelapenya marked this conversation as resolved.
Show resolved Hide resolved

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

export type HealthCheckStatus = "none" | "starting" | "unhealthy" | "healthy";
Expand Down