From 6a54b4718e60651b84afa766092f3fa65cd265c5 Mon Sep 17 00:00:00 2001 From: Lorenzo Delgado Date: Thu, 23 Nov 2023 15:44:52 +0100 Subject: [PATCH] Add support for setting `user`, `workingDir` and `env` when executing a command in a container (#668) --- docs/features/containers.md | 27 +++++++++++++++ .../clients/container/container-client.ts | 4 +-- .../container/docker-container-client.ts | 31 +++++++++++------ .../container/podman-container-client.ts | 34 +++++++++++++------ .../clients/container/types.ts | 4 +++ .../abstract-started-container.ts | 6 ++-- .../generic-container.test.ts | 34 +++++++++++++++++++ .../started-generic-container.ts | 6 ++-- packages/testcontainers/src/index.ts | 2 +- packages/testcontainers/src/test-container.ts | 3 +- packages/testcontainers/src/types.ts | 2 ++ 11 files changed, 123 insertions(+), 30 deletions(-) diff --git a/docs/features/containers.md b/docs/features/containers.md index 00589a428..1be73a96b 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -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"]) @@ -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: diff --git a/packages/testcontainers/src/container-runtime/clients/container/container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/container-client.ts index fb55a069e..e19eab593 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/container-client.ts @@ -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; @@ -22,7 +22,7 @@ export interface ContainerClient { stop(container: Container, opts?: { timeout: number }): Promise; attach(container: Container): Promise; logs(container: Container, opts?: ContainerLogsOptions): Promise; - exec(container: Container, command: string[], opts?: { log: boolean }): Promise; + exec(container: Container, command: string[], opts?: Partial): Promise; restart(container: Container, opts?: { timeout: number }): Promise; remove(container: Container, opts?: { removeVolumes: boolean }): Promise; connectToNetwork(container: Container, network: Network, networkAliases: string[]): Promise; diff --git a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts index 9d5fe1535..8043d56ed 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts @@ -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"; @@ -173,21 +174,31 @@ export class DockerContainerClient implements ContainerClient { } } - async exec(container: Container, command: string[], opts?: { log: boolean }): Promise { - const chunks: string[] = []; + async exec(container: Container, command: string[], opts?: Partial): Promise { + 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 })); } @@ -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 }); diff --git a/packages/testcontainers/src/container-runtime/clients/container/podman-container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/podman-container-client.ts index f44ca3966..20a0fab4f 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/podman-container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/podman-container-client.ts @@ -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 { - const chunks: string[] = []; + override async exec(container: Container, command: string[], opts?: Partial): Promise { + 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 })); @@ -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 }; diff --git a/packages/testcontainers/src/container-runtime/clients/container/types.ts b/packages/testcontainers/src/container-runtime/clients/container/types.ts index dde9cecfa..127289cdb 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/types.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/types.ts @@ -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 }; diff --git a/packages/testcontainers/src/generic-container/abstract-started-container.ts b/packages/testcontainers/src/generic-container/abstract-started-container.ts index 3802c6ed6..b8a646b7d 100644 --- a/packages/testcontainers/src/generic-container/abstract-started-container.ts +++ b/packages/testcontainers/src/generic-container/abstract-started-container.ts @@ -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 { @@ -79,8 +79,8 @@ export class AbstractStartedContainer implements StartedTestContainer { return this.startedTestContainer.copyArchiveFromContainer(path); } - public exec(command: string | string[]): Promise { - return this.startedTestContainer.exec(command); + public exec(command: string | string[], opts?: Partial): Promise { + return this.startedTestContainer.exec(command, opts); } public logs(opts?: { since?: number }): Promise { diff --git a/packages/testcontainers/src/generic-container/generic-container.test.ts b/packages/testcontainers/src/generic-container/generic-container.test.ts index 6055fb880..d8b3b8afd 100644 --- a/packages/testcontainers/src/generic-container/generic-container.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container.test.ts @@ -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" }) diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 698024484..d758f2452 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -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"; @@ -170,12 +170,12 @@ export class StartedGenericContainer implements StartedTestContainer { return stream; } - public async exec(command: string | string[]): Promise { + public async exec(command: string | string[], opts?: Partial): Promise { 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; diff --git a/packages/testcontainers/src/index.ts b/packages/testcontainers/src/index.ts index df1692b7e..69f357189 100644 --- a/packages/testcontainers/src/index.ts +++ b/packages/testcontainers/src/index.ts @@ -21,7 +21,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"; export { AbstractStartedContainer } from "./generic-container/abstract-started-container"; export { AbstractStoppedContainer } from "./generic-container/abstract-stopped-container"; diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index 53f56dd0c..dafeeeed3 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -5,6 +5,7 @@ import { ContentToCopy, DirectoryToCopy, Environment, + ExecOptions, ExecResult, ExtraHost, FileToCopy, @@ -73,7 +74,7 @@ export interface StartedTestContainer { copyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): Promise; copyFilesToContainer(filesToCopy: FileToCopy[]): Promise; copyContentToContainer(contentsToCopy: ContentToCopy[]): Promise; - exec(command: string | string[]): Promise; + exec(command: string | string[], opts?: Partial): Promise; logs(opts?: { since?: number }): Promise; } diff --git a/packages/testcontainers/src/types.ts b/packages/testcontainers/src/types.ts index 29611b5ed..2840dd4f0 100644 --- a/packages/testcontainers/src/types.ts +++ b/packages/testcontainers/src/types.ts @@ -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";