From 51ee497d89c3a6050ec52573bd8116174b2d6fb1 Mon Sep 17 00:00:00 2001 From: Sean McRoskey Date: Tue, 11 Mar 2025 22:00:36 -0700 Subject: [PATCH 1/7] feat: add support for container.commit() [884] --- docs/features/containers.md | 10 ++++ .../clients/container/container-client.ts | 3 +- .../container/docker-container-client.ts | 13 ++++- .../clients/container/types.ts | 2 + .../abstract-started-container.ts | 12 ++++- .../generic-container-commit.test.ts | 52 +++++++++++++++++++ .../started-generic-container.ts | 8 +++ packages/testcontainers/src/test-container.ts | 10 ++++ .../testcontainers/src/utils/test-helper.ts | 9 +++- 9 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 packages/testcontainers/src/generic-container/generic-container-commit.test.ts diff --git a/docs/features/containers.md b/docs/features/containers.md index e4c8caffd..f0496bdee 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -357,6 +357,16 @@ const container = await new GenericContainer("alpine").start(); await container.restart(); ``` +## Committing a container to an image + +```javascript +const container = await new GenericContainer("alpine").start(); +// Do something with the container +await container.exec(["sh", "-c", `echo 'hello world' > /hello-world.txt`]); +// Commit the container to an image +const newImage = await container.commit({ repository: "my-repo", tag: "my-tag" }); +``` + ## Reusing a container Enabling container re-use means that Testcontainers will not start a new container if a Testcontainers managed container with the same configuration is already running. 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 857dd35ca..bd76c3b33 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 { ContainerStatus, ExecOptions, ExecResult } from "./types"; +import { CommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types"; export interface ContainerClient { dockerode: Dockerode; @@ -31,6 +31,7 @@ export interface ContainerClient { logs(container: Container, opts?: ContainerLogsOptions): Promise; exec(container: Container, command: string[], opts?: Partial): Promise; restart(container: Container, opts?: { timeout: number }): Promise; + commit(container: Container, opts?: CommitOptions): Promise; events(container: Container, eventNames: string[]): 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 e5087daf5..03ad9f5c5 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 @@ -11,7 +11,7 @@ import { IncomingMessage } from "http"; import { PassThrough, Readable } from "stream"; import { execLog, log, streamToString } from "../../../common"; import { ContainerClient } from "./container-client"; -import { ContainerStatus, ExecOptions, ExecResult } from "./types"; +import { CommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types"; export class DockerContainerClient implements ContainerClient { constructor(public readonly dockerode: Dockerode) {} @@ -264,6 +264,17 @@ export class DockerContainerClient implements ContainerClient { } } + async commit(container: Container, opts?: CommitOptions): Promise { + try { + log.debug(`Committing container...`, { containerId: container.id }); + await container.commit(opts); + log.debug(`Committed container image`, { containerId: container.id }); + } catch (err) { + log.error(`Failed to commit container: ${err}`, { containerId: container.id }); + throw err; + } + } + async remove(container: Container, opts?: { removeVolumes: boolean }): Promise { try { log.debug(`Removing container...`, { containerId: container.id }); diff --git a/packages/testcontainers/src/container-runtime/clients/container/types.ts b/packages/testcontainers/src/container-runtime/clients/container/types.ts index c076a71f5..a8fae7d8f 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/types.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/types.ts @@ -4,6 +4,8 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment; export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number }; +export type CommitOptions = { repo?: string; tag?: string; comment?: string; author?: string; pause?: boolean }; + export const CONTAINER_STATUSES = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const; export type ContainerStatus = (typeof CONTAINER_STATUSES)[number]; diff --git a/packages/testcontainers/src/generic-container/abstract-started-container.ts b/packages/testcontainers/src/generic-container/abstract-started-container.ts index d4b7f40f1..7c1b49b5f 100644 --- a/packages/testcontainers/src/generic-container/abstract-started-container.ts +++ b/packages/testcontainers/src/generic-container/abstract-started-container.ts @@ -1,5 +1,11 @@ import { Readable } from "stream"; -import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; +import { + CommitOptions, + RestartOptions, + StartedTestContainer, + StopOptions, + StoppedTestContainer, +} from "../test-container"; import { ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types"; export class AbstractStartedContainer implements StartedTestContainer { @@ -27,6 +33,10 @@ export class AbstractStartedContainer implements StartedTestContainer { return this.startedTestContainer.restart(options); } + public async commit(options?: Partial): Promise { + return this.startedTestContainer.commit(options); + } + public getHost(): string { return this.startedTestContainer.getHost(); } diff --git a/packages/testcontainers/src/generic-container/generic-container-commit.test.ts b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts new file mode 100644 index 000000000..b72a36f8f --- /dev/null +++ b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts @@ -0,0 +1,52 @@ +import { RandomUuid } from "../common"; +import { deleteImageByName, getImageInfo, getImageLabelsByName } from "../utils/test-helper"; +import { GenericContainer } from "./generic-container"; + +describe("GenericContainer commit", { timeout: 180_000 }, () => { + const imageName = "cristianrgreco/testcontainer"; + const imageVersion = "1.1.14"; + + it("should commit container changes to a new image", async () => { + const testContent = "test content"; + const newImageTag = `test-commit-${new RandomUuid().nextUuid()}`; + const testAuthor = "test-author"; + const testComment = "test-comment"; + + // Start original container and make a change + const container = await new GenericContainer(`${imageName}:${imageVersion}`) + .withName(`container-${new RandomUuid().nextUuid()}`) + .withExposedPorts(8080) + .start(); + + // Make a change to the container + await container.exec(["sh", "-c", `echo '${testContent}' > /test-file.txt`]); + + // Commit the changes to a new image + await container.commit({ + repo: imageName, + tag: newImageTag, + author: testAuthor, + comment: testComment, + }); + + // Verify image author and comment are set + const imageInfo = await getImageInfo(`${imageName}:${newImageTag}`); + expect(imageInfo.Author).toBe(testAuthor); + expect(imageInfo.Comment).toBe(testComment); + + // Start a new container from the committed image + const newContainer = await new GenericContainer(`${imageName}:${newImageTag}`) + .withName(`container-${new RandomUuid().nextUuid()}`) + .withExposedPorts(8080) + .start(); + + // Verify the changes exist in the new container + const result = await newContainer.exec(["cat", "/test-file.txt"]); + expect(result.output.trim()).toBe(testContent); + + // Cleanup + await container.stop(); + await newContainer.stop(); + await deleteImageByName(`${imageName}:${newImageTag}`); + }); +}); diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index b412f6cfd..7f3a589c9 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -11,6 +11,7 @@ import { mapInspectResult } from "../utils/map-inspect-result"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; import { StoppedGenericContainer } from "./stopped-generic-container"; +import { CommitOptions } from "../container-runtime/clients/container/types"; export class StartedGenericContainer implements StartedTestContainer { private stoppedContainer?: StoppedTestContainer; @@ -37,6 +38,13 @@ export class StartedGenericContainer implements StartedTestContainer { }); } + public async commit(options?: Partial): Promise { + log.info(`Committing container image...`, { containerId: this.container.id }); + const client = await getContainerRuntimeClient(); + await client.container.commit(this.container, options); + log.info(`Committed container image`, { containerId: this.container.id }); + } + protected containerIsStopped?(): Promise; public async restart(options: Partial = {}): Promise { diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index 56a47e271..26b2f285e 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -43,6 +43,7 @@ export interface TestContainer { withCopyFilesToContainer(filesToCopy: FileToCopy[]): this; withCopyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): this; withCopyContentToContainer(contentsToCopy: ContentToCopy[]): this; + withWorkingDir(workingDir: string): this; withResourcesQuota(resourcesQuota: ResourcesQuota): this; withSharedMemorySize(bytes: number): this; @@ -60,9 +61,18 @@ export interface StopOptions { removeVolumes: boolean; } +export interface CommitOptions { + repo?: string; + tag?: string; + comment?: string; + author?: string; + pause?: boolean; +} + export interface StartedTestContainer { stop(options?: Partial): Promise; restart(options?: Partial): Promise; + commit(options?: Partial): Promise; getHost(): string; getHostname(): string; getFirstMappedPort(): number; diff --git a/packages/testcontainers/src/utils/test-helper.ts b/packages/testcontainers/src/utils/test-helper.ts index 1a0aeb26e..3f9ef2af4 100644 --- a/packages/testcontainers/src/utils/test-helper.ts +++ b/packages/testcontainers/src/utils/test-helper.ts @@ -1,4 +1,4 @@ -import { GetEventsOptions } from "dockerode"; +import { GetEventsOptions, ImageInspectInfo } from "dockerode"; import { Readable } from "stream"; import { Agent } from "undici"; import { IntervalRetry } from "../common"; @@ -50,6 +50,13 @@ export const getContainerIds = async (): Promise => { return containers.map((container) => container.Id); }; +export const getImageInfo = async (imageName: string): Promise => { + const dockerode = (await getContainerRuntimeClient()).container.dockerode; + const image = dockerode.getImage(imageName); + const imageInfo = await image.inspect(); + return imageInfo; +} + export const checkImageExists = async (imageName: string): Promise => { const dockerode = (await getContainerRuntimeClient()).container.dockerode; try { From 74585dc1349bc5438518291cb68b7c93fc5e8b98 Mon Sep 17 00:00:00 2001 From: Sean McRoskey Date: Wed, 12 Mar 2025 13:21:27 -0700 Subject: [PATCH 2/7] lint fixes --- .../src/generic-container/generic-container-commit.test.ts | 2 +- .../src/generic-container/started-generic-container.ts | 2 +- packages/testcontainers/src/utils/test-helper.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/testcontainers/src/generic-container/generic-container-commit.test.ts b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts index b72a36f8f..d2b0ffcae 100644 --- a/packages/testcontainers/src/generic-container/generic-container-commit.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts @@ -1,5 +1,5 @@ import { RandomUuid } from "../common"; -import { deleteImageByName, getImageInfo, getImageLabelsByName } from "../utils/test-helper"; +import { deleteImageByName, getImageInfo } from "../utils/test-helper"; import { GenericContainer } from "./generic-container"; describe("GenericContainer commit", { timeout: 180_000 }, () => { diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 7f3a589c9..747a69aed 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -4,6 +4,7 @@ import Dockerode, { ContainerInspectInfo } from "dockerode"; import { Readable } from "stream"; import { containerLog, log } from "../common"; import { getContainerRuntimeClient } from "../container-runtime"; +import { CommitOptions } from "../container-runtime/clients/container/types"; import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; import { ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types"; import { BoundPorts } from "../utils/bound-ports"; @@ -11,7 +12,6 @@ import { mapInspectResult } from "../utils/map-inspect-result"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; import { StoppedGenericContainer } from "./stopped-generic-container"; -import { CommitOptions } from "../container-runtime/clients/container/types"; export class StartedGenericContainer implements StartedTestContainer { private stoppedContainer?: StoppedTestContainer; diff --git a/packages/testcontainers/src/utils/test-helper.ts b/packages/testcontainers/src/utils/test-helper.ts index 3f9ef2af4..d99a55358 100644 --- a/packages/testcontainers/src/utils/test-helper.ts +++ b/packages/testcontainers/src/utils/test-helper.ts @@ -55,7 +55,7 @@ export const getImageInfo = async (imageName: string): Promise const image = dockerode.getImage(imageName); const imageInfo = await image.inspect(); return imageInfo; -} +}; export const checkImageExists = async (imageName: string): Promise => { const dockerode = (await getContainerRuntimeClient()).container.dockerode; From f8c67aa534bc2be3787d12f8bdb275b89b1cbc8b Mon Sep 17 00:00:00 2001 From: Sean McRoskey Date: Wed, 12 Mar 2025 13:58:32 -0700 Subject: [PATCH 3/7] update method signature to return image ID --- docs/features/containers.md | 2 +- .../clients/container/container-client.ts | 2 +- .../clients/container/docker-container-client.ts | 7 ++++--- .../container-runtime/clients/container/types.ts | 2 +- .../testcontainers/src/container-runtime/index.ts | 1 + .../generic-container-commit.test.ts | 14 ++++---------- .../generic-container/started-generic-container.ts | 7 ++++--- packages/testcontainers/src/index.ts | 2 +- packages/testcontainers/src/test-container.ts | 11 ++--------- 9 files changed, 19 insertions(+), 29 deletions(-) diff --git a/docs/features/containers.md b/docs/features/containers.md index f0496bdee..fb99b9594 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -364,7 +364,7 @@ const container = await new GenericContainer("alpine").start(); // Do something with the container await container.exec(["sh", "-c", `echo 'hello world' > /hello-world.txt`]); // Commit the container to an image -const newImage = await container.commit({ repository: "my-repo", tag: "my-tag" }); +const newImageId = await container.commit({ repo: "my-repo", tag: "my-tag" }); ``` ## Reusing a 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 bd76c3b33..91cd6e65c 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/container-client.ts @@ -31,7 +31,7 @@ export interface ContainerClient { logs(container: Container, opts?: ContainerLogsOptions): Promise; exec(container: Container, command: string[], opts?: Partial): Promise; restart(container: Container, opts?: { timeout: number }): Promise; - commit(container: Container, opts?: CommitOptions): Promise; + commit(container: Container, opts?: CommitOptions): Promise; events(container: Container, eventNames: string[]): 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 03ad9f5c5..cbf0b63de 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 @@ -264,11 +264,12 @@ export class DockerContainerClient implements ContainerClient { } } - async commit(container: Container, opts?: CommitOptions): Promise { + async commit(container: Container, opts?: CommitOptions): Promise { try { log.debug(`Committing container...`, { containerId: container.id }); - await container.commit(opts); - log.debug(`Committed container image`, { containerId: container.id }); + const { Id: imageId } = await container.commit(opts); + log.debug(`Committed container to image (Image ID: ${imageId})`, { containerId: container.id }); + return imageId; } catch (err) { log.error(`Failed to commit container: ${err}`, { containerId: container.id }); throw err; diff --git a/packages/testcontainers/src/container-runtime/clients/container/types.ts b/packages/testcontainers/src/container-runtime/clients/container/types.ts index a8fae7d8f..686b9f0fb 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/types.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/types.ts @@ -4,7 +4,7 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment; export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number }; -export type CommitOptions = { repo?: string; tag?: string; comment?: string; author?: string; pause?: boolean }; +export type CommitOptions = { repo: string; tag: string; comment?: string; author?: string; pause?: boolean }; export const CONTAINER_STATUSES = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const; diff --git a/packages/testcontainers/src/container-runtime/index.ts b/packages/testcontainers/src/container-runtime/index.ts index 6717aaec5..659b1a020 100644 --- a/packages/testcontainers/src/container-runtime/index.ts +++ b/packages/testcontainers/src/container-runtime/index.ts @@ -2,5 +2,6 @@ export { getAuthConfig } from "./auth/get-auth-config"; export { ContainerRuntimeClient, getContainerRuntimeClient } from "./clients/client"; export { parseComposeContainerName } from "./clients/compose/parse-compose-container-name"; export { ComposeDownOptions, ComposeOptions } from "./clients/compose/types"; +export { CommitOptions } from "./clients/container/types"; export { HostIp } from "./clients/types"; export { ImageName } from "./image-name"; diff --git a/packages/testcontainers/src/generic-container/generic-container-commit.test.ts b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts index d2b0ffcae..16c379844 100644 --- a/packages/testcontainers/src/generic-container/generic-container-commit.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts @@ -13,16 +13,13 @@ describe("GenericContainer commit", { timeout: 180_000 }, () => { const testComment = "test-comment"; // Start original container and make a change - const container = await new GenericContainer(`${imageName}:${imageVersion}`) - .withName(`container-${new RandomUuid().nextUuid()}`) - .withExposedPorts(8080) - .start(); + const container = await new GenericContainer(`${imageName}:${imageVersion}`).withExposedPorts(8080).start(); // Make a change to the container await container.exec(["sh", "-c", `echo '${testContent}' > /test-file.txt`]); // Commit the changes to a new image - await container.commit({ + const imageId = await container.commit({ repo: imageName, tag: newImageTag, author: testAuthor, @@ -30,15 +27,12 @@ describe("GenericContainer commit", { timeout: 180_000 }, () => { }); // Verify image author and comment are set - const imageInfo = await getImageInfo(`${imageName}:${newImageTag}`); + const imageInfo = await getImageInfo(imageId); expect(imageInfo.Author).toBe(testAuthor); expect(imageInfo.Comment).toBe(testComment); // Start a new container from the committed image - const newContainer = await new GenericContainer(`${imageName}:${newImageTag}`) - .withName(`container-${new RandomUuid().nextUuid()}`) - .withExposedPorts(8080) - .start(); + const newContainer = await new GenericContainer(imageId).withExposedPorts(8080).start(); // Verify the changes exist in the new container const result = await newContainer.exec(["cat", "/test-file.txt"]); diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 747a69aed..9755903eb 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -38,11 +38,12 @@ export class StartedGenericContainer implements StartedTestContainer { }); } - public async commit(options?: Partial): Promise { + public async commit(options?: CommitOptions): Promise { log.info(`Committing container image...`, { containerId: this.container.id }); const client = await getContainerRuntimeClient(); - await client.container.commit(this.container, options); - log.info(`Committed container image`, { containerId: this.container.id }); + const imageId = await client.container.commit(this.container, options); + log.info(`Committed container image (Image ID: ${imageId}`, { containerId: this.container.id }); + return imageId; } protected containerIsStopped?(): Promise; diff --git a/packages/testcontainers/src/index.ts b/packages/testcontainers/src/index.ts index 47c9be0e2..6e88a4642 100644 --- a/packages/testcontainers/src/index.ts +++ b/packages/testcontainers/src/index.ts @@ -1,5 +1,5 @@ export { IntervalRetry, log, RandomUuid, Retry, Uuid } from "./common"; -export { ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "./container-runtime"; +export { CommitOptions, ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "./container-runtime"; export { DockerComposeEnvironment } from "./docker-compose-environment/docker-compose-environment"; export { DownedDockerComposeEnvironment } from "./docker-compose-environment/downed-docker-compose-environment"; export { StartedDockerComposeEnvironment } from "./docker-compose-environment/started-docker-compose-environment"; diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index 26b2f285e..c02008c0c 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -1,4 +1,5 @@ import { Readable } from "stream"; +import { CommitOptions } from "./container-runtime"; import { StartedNetwork } from "./network/network"; import { BindMount, @@ -61,18 +62,10 @@ export interface StopOptions { removeVolumes: boolean; } -export interface CommitOptions { - repo?: string; - tag?: string; - comment?: string; - author?: string; - pause?: boolean; -} - export interface StartedTestContainer { stop(options?: Partial): Promise; restart(options?: Partial): Promise; - commit(options?: Partial): Promise; + commit(options?: Partial): Promise; getHost(): string; getHostname(): string; getFirstMappedPort(): number; From 097578a0e4315ff352b7b1286c1508694c2b9e95 Mon Sep 17 00:00:00 2001 From: Sean McRoskey Date: Wed, 12 Mar 2025 15:39:29 -0700 Subject: [PATCH 4/7] add labels (use labels from source image) --- .../clients/container/container-client.ts | 2 +- .../container/docker-container-client.ts | 3 +- .../clients/container/types.ts | 9 +++- .../clients/image/docker-image-client.ts | 14 +++++- .../clients/image/image-client.ts | 3 +- .../abstract-started-container.ts | 18 +++---- .../generic-container-commit.test.ts | 48 ++++++++++++++++++- .../started-generic-container.ts | 27 +++++++++-- packages/testcontainers/src/test-container.ts | 4 +- packages/testcontainers/src/types.ts | 3 ++ 10 files changed, 109 insertions(+), 22 deletions(-) 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 91cd6e65c..9674491d7 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/container-client.ts @@ -31,7 +31,7 @@ export interface ContainerClient { logs(container: Container, opts?: ContainerLogsOptions): Promise; exec(container: Container, command: string[], opts?: Partial): Promise; restart(container: Container, opts?: { timeout: number }): Promise; - commit(container: Container, opts?: CommitOptions): Promise; + commit(container: Container, opts: CommitOptions): Promise; events(container: Container, eventNames: string[]): 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 cbf0b63de..cd902e2e2 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 @@ -12,6 +12,7 @@ import { PassThrough, Readable } from "stream"; import { execLog, log, streamToString } from "../../../common"; import { ContainerClient } from "./container-client"; import { CommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types"; +import { createLabels, LABEL_TESTCONTAINERS_SESSION_ID } from "../../../utils/labels"; export class DockerContainerClient implements ContainerClient { constructor(public readonly dockerode: Dockerode) {} @@ -264,7 +265,7 @@ export class DockerContainerClient implements ContainerClient { } } - async commit(container: Container, opts?: CommitOptions): Promise { + async commit(container: Container, opts: CommitOptions): Promise { try { log.debug(`Committing container...`, { containerId: container.id }); const { Id: imageId } = await container.commit(opts); diff --git a/packages/testcontainers/src/container-runtime/clients/container/types.ts b/packages/testcontainers/src/container-runtime/clients/container/types.ts index 686b9f0fb..349813e3c 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/types.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/types.ts @@ -4,7 +4,14 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment; export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number }; -export type CommitOptions = { repo: string; tag: string; comment?: string; author?: string; pause?: boolean }; +export type CommitOptions = { + repo: string; + tag: string; + comment?: string; + author?: string; + pause?: boolean; + labels?: { [key: string]: string }; +}; export const CONTAINER_STATUSES = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const; diff --git a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts index 9dd630934..b65590978 100644 --- a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts @@ -1,7 +1,7 @@ import dockerIgnore from "@balena/dockerignore"; import AsyncLock from "async-lock"; import byline from "byline"; -import Dockerode, { ImageBuildOptions } from "dockerode"; +import Dockerode, { ImageBuildOptions, ImageInspectInfo } from "dockerode"; import { existsSync, promises as fs } from "fs"; import path from "path"; import tar from "tar-fs"; @@ -65,6 +65,18 @@ export class DockerImageClient implements ImageClient { return (aPath: string) => !filter(aPath); } + async inspect(imageName: ImageName): Promise { + try { + log.debug(`Inspecting image "${imageName.string}"...`); + const imageInfo = await this.dockerode.getImage(imageName.string).inspect(); + log.debug(`Retrieved image info "${imageName.string}"`); + return imageInfo; + } catch (err) { + log.debug(`Failed to inspect image "${imageName.string}"`); + throw err; + } + } + async exists(imageName: ImageName): Promise { return this.imageExistsLock.acquire(imageName.string, async () => { if (this.existingImages.has(imageName.string)) { diff --git a/packages/testcontainers/src/container-runtime/clients/image/image-client.ts b/packages/testcontainers/src/container-runtime/clients/image/image-client.ts index a4b92f2db..d71cf619f 100644 --- a/packages/testcontainers/src/container-runtime/clients/image/image-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/image/image-client.ts @@ -1,8 +1,9 @@ -import { ImageBuildOptions } from "dockerode"; +import { ImageBuildOptions, ImageInspectInfo } from "dockerode"; import { ImageName } from "../../image-name"; export interface ImageClient { build(context: string, opts: ImageBuildOptions): Promise; pull(imageName: ImageName, opts?: { force: boolean; platform: string | undefined }): Promise; + inspect(imageName: ImageName): Promise; exists(imageName: ImageName): Promise; } diff --git a/packages/testcontainers/src/generic-container/abstract-started-container.ts b/packages/testcontainers/src/generic-container/abstract-started-container.ts index 7c1b49b5f..372652f43 100644 --- a/packages/testcontainers/src/generic-container/abstract-started-container.ts +++ b/packages/testcontainers/src/generic-container/abstract-started-container.ts @@ -1,12 +1,14 @@ import { Readable } from "stream"; +import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; import { - CommitOptions, - RestartOptions, - StartedTestContainer, - StopOptions, - StoppedTestContainer, -} from "../test-container"; -import { ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types"; + ContainerCommitOptions, + ContentToCopy, + DirectoryToCopy, + ExecOptions, + ExecResult, + FileToCopy, + Labels, +} from "../types"; export class AbstractStartedContainer implements StartedTestContainer { constructor(protected readonly startedTestContainer: StartedTestContainer) {} @@ -33,7 +35,7 @@ export class AbstractStartedContainer implements StartedTestContainer { return this.startedTestContainer.restart(options); } - public async commit(options?: Partial): Promise { + public async commit(options: ContainerCommitOptions): Promise { return this.startedTestContainer.commit(options); } diff --git a/packages/testcontainers/src/generic-container/generic-container-commit.test.ts b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts index 16c379844..adc3ec967 100644 --- a/packages/testcontainers/src/generic-container/generic-container-commit.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts @@ -1,4 +1,6 @@ +import path from "path"; import { RandomUuid } from "../common"; +import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; import { deleteImageByName, getImageInfo } from "../utils/test-helper"; import { GenericContainer } from "./generic-container"; @@ -6,6 +8,8 @@ describe("GenericContainer commit", { timeout: 180_000 }, () => { const imageName = "cristianrgreco/testcontainer"; const imageVersion = "1.1.14"; + const fixtures = path.resolve(__dirname, "..", "..", "fixtures", "docker"); + it("should commit container changes to a new image", async () => { const testContent = "test content"; const newImageTag = `test-commit-${new RandomUuid().nextUuid()}`; @@ -26,7 +30,7 @@ describe("GenericContainer commit", { timeout: 180_000 }, () => { comment: testComment, }); - // Verify image author and comment are set + // Verify image metadata is set const imageInfo = await getImageInfo(imageId); expect(imageInfo.Author).toBe(testAuthor); expect(imageInfo.Comment).toBe(testComment); @@ -41,6 +45,46 @@ describe("GenericContainer commit", { timeout: 180_000 }, () => { // Cleanup await container.stop(); await newContainer.stop(); - await deleteImageByName(`${imageName}:${newImageTag}`); + }); + + it("should add session ID label when deleteOnExit is true", async () => { + const newImageTag = `test-commit-${new RandomUuid().nextUuid()}`; + const container = await new GenericContainer(`${imageName}:${imageVersion}`).withExposedPorts(8080).start(); + + // Commit with deleteOnExit true (default) + const imageId = await container.commit({ + repo: imageName, + tag: newImageTag, + }); + + // Verify session ID label is present + const imageInfo = await getImageInfo(imageId); + expect(imageInfo.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBeDefined(); + + await container.stop(); + }); + + it("should not add session ID label when deleteOnExit is false", async () => { + const newImageTag = `test-commit-${new RandomUuid().nextUuid()}`; + const context = path.resolve(fixtures, "docker"); + const container = await GenericContainer.fromDockerfile(context).build(`${imageName}:no-delete-on-exit`, { + deleteOnExit: false, + }); + + const startedContainer = await container.start(); + + // Commit with deleteOnExit false + const imageId = await startedContainer.commit({ + repo: imageName, + tag: newImageTag, + deleteOnExit: false, + }); + + // Verify session ID label is not present + const imageInfo = await getImageInfo(imageId); + expect(imageInfo.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBeUndefined(); + + await startedContainer.stop(); + await deleteImageByName(imageId); }); }); diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 9755903eb..3e0556a26 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -3,11 +3,20 @@ import AsyncLock from "async-lock"; import Dockerode, { ContainerInspectInfo } from "dockerode"; import { Readable } from "stream"; import { containerLog, log } from "../common"; -import { getContainerRuntimeClient } from "../container-runtime"; -import { CommitOptions } from "../container-runtime/clients/container/types"; +import { getContainerRuntimeClient, ImageName } from "../container-runtime"; +import { getReaper } from "../reaper/reaper"; import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; -import { ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types"; +import { + ContainerCommitOptions, + ContentToCopy, + DirectoryToCopy, + ExecOptions, + ExecResult, + FileToCopy, + Labels, +} from "../types"; import { BoundPorts } from "../utils/bound-ports"; +import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; import { mapInspectResult } from "../utils/map-inspect-result"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; @@ -38,10 +47,18 @@ export class StartedGenericContainer implements StartedTestContainer { }); } - public async commit(options?: CommitOptions): Promise { + public async commit(options: ContainerCommitOptions): Promise { log.info(`Committing container image...`, { containerId: this.container.id }); const client = await getContainerRuntimeClient(); - const imageId = await client.container.commit(this.container, options); + const { deleteOnExit = true, ...commitOpts } = options; + const image = await client.image.inspect(ImageName.fromString(this.inspectResult.Config.Image)); + const labels = { ...image.Config.Labels, ...commitOpts.labels }; + if (deleteOnExit && !labels[LABEL_TESTCONTAINERS_SESSION_ID]) { + const reaper = await getReaper(client); + labels[LABEL_TESTCONTAINERS_SESSION_ID] = reaper.sessionId; + } + const commitOptions = { ...commitOpts, labels }; + const imageId = await client.container.commit(this.container, commitOptions); log.info(`Committed container image (Image ID: ${imageId}`, { containerId: this.container.id }); return imageId; } diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index c02008c0c..2e7091640 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -1,8 +1,8 @@ import { Readable } from "stream"; -import { CommitOptions } from "./container-runtime"; import { StartedNetwork } from "./network/network"; import { BindMount, + ContainerCommitOptions, ContentToCopy, DirectoryToCopy, Environment, @@ -65,7 +65,7 @@ export interface StopOptions { export interface StartedTestContainer { stop(options?: Partial): Promise; restart(options?: Partial): Promise; - commit(options?: Partial): Promise; + commit(options: ContainerCommitOptions): Promise; getHost(): string; getHostname(): string; getFirstMappedPort(): number; diff --git a/packages/testcontainers/src/types.ts b/packages/testcontainers/src/types.ts index ad8eb865e..e45ec1c12 100644 --- a/packages/testcontainers/src/types.ts +++ b/packages/testcontainers/src/types.ts @@ -1,4 +1,5 @@ import { Readable } from "stream"; +import { CommitOptions } from "./container-runtime"; export type InspectResult = { name: string; @@ -85,6 +86,8 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment } export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number }; +export type ContainerCommitOptions = CommitOptions & { deleteOnExit?: boolean }; + export type HealthCheckStatus = "none" | "starting" | "unhealthy" | "healthy"; export type NetworkSettings = { From 55b19aa9cbf2a9e2116e05881b71341655c0b3c6 Mon Sep 17 00:00:00 2001 From: Sean McRoskey Date: Wed, 12 Mar 2025 16:28:15 -0700 Subject: [PATCH 5/7] expose deleteOnExit & changes options --- docs/features/containers.md | 2 +- .../container/docker-container-client.ts | 1 - .../clients/container/types.ts | 2 +- .../generic-container-commit.test.ts | 23 +++++------ .../started-generic-container.ts | 38 ++++++++++++++----- packages/testcontainers/src/types.ts | 7 +++- 6 files changed, 46 insertions(+), 27 deletions(-) diff --git a/docs/features/containers.md b/docs/features/containers.md index fb99b9594..b9b1e0b6d 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -364,7 +364,7 @@ const container = await new GenericContainer("alpine").start(); // Do something with the container await container.exec(["sh", "-c", `echo 'hello world' > /hello-world.txt`]); // Commit the container to an image -const newImageId = await container.commit({ repo: "my-repo", tag: "my-tag" }); +const newImageId = await container.commit({ repo: "my-repo", tag: "my-tag", deleteOnExit: false }); ``` ## Reusing a container 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 cd902e2e2..3af4b1fa0 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 @@ -12,7 +12,6 @@ import { PassThrough, Readable } from "stream"; import { execLog, log, streamToString } from "../../../common"; import { ContainerClient } from "./container-client"; import { CommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types"; -import { createLabels, LABEL_TESTCONTAINERS_SESSION_ID } from "../../../utils/labels"; export class DockerContainerClient implements ContainerClient { constructor(public readonly dockerode: Dockerode) {} diff --git a/packages/testcontainers/src/container-runtime/clients/container/types.ts b/packages/testcontainers/src/container-runtime/clients/container/types.ts index 349813e3c..4af0265c7 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/types.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/types.ts @@ -10,7 +10,7 @@ export type CommitOptions = { comment?: string; author?: string; pause?: boolean; - labels?: { [key: string]: string }; + changes?: string; }; export const CONTAINER_STATUSES = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const; diff --git a/packages/testcontainers/src/generic-container/generic-container-commit.test.ts b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts index adc3ec967..47c859344 100644 --- a/packages/testcontainers/src/generic-container/generic-container-commit.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts @@ -1,4 +1,4 @@ -import path from "path"; +import { expect } from "vitest"; import { RandomUuid } from "../common"; import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; import { deleteImageByName, getImageInfo } from "../utils/test-helper"; @@ -8,8 +8,6 @@ describe("GenericContainer commit", { timeout: 180_000 }, () => { const imageName = "cristianrgreco/testcontainer"; const imageVersion = "1.1.14"; - const fixtures = path.resolve(__dirname, "..", "..", "fixtures", "docker"); - it("should commit container changes to a new image", async () => { const testContent = "test content"; const newImageTag = `test-commit-${new RandomUuid().nextUuid()}`; @@ -66,25 +64,24 @@ describe("GenericContainer commit", { timeout: 180_000 }, () => { it("should not add session ID label when deleteOnExit is false", async () => { const newImageTag = `test-commit-${new RandomUuid().nextUuid()}`; - const context = path.resolve(fixtures, "docker"); - const container = await GenericContainer.fromDockerfile(context).build(`${imageName}:no-delete-on-exit`, { - deleteOnExit: false, - }); - - const startedContainer = await container.start(); + const container = await new GenericContainer(`${imageName}:${imageVersion}`).withExposedPorts(8080).start(); // Commit with deleteOnExit false - const imageId = await startedContainer.commit({ + const imageId = await container.commit({ repo: imageName, tag: newImageTag, + changes: ["LABEL test=test", "ENV test=test"], deleteOnExit: false, }); - // Verify session ID label is not present const imageInfo = await getImageInfo(imageId); - expect(imageInfo.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBeUndefined(); + // Verify session ID label is not present + expect(imageInfo.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBeFalsy(); + // Verify other changes are present + expect(imageInfo.Config.Labels.test).toBe("test"); + expect(imageInfo.Config.Env).toContain("test=test"); - await startedContainer.stop(); + await container.stop(); await deleteImageByName(imageId); }); }); diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 3e0556a26..fc705927f 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -3,7 +3,7 @@ import AsyncLock from "async-lock"; import Dockerode, { ContainerInspectInfo } from "dockerode"; import { Readable } from "stream"; import { containerLog, log } from "../common"; -import { getContainerRuntimeClient, ImageName } from "../container-runtime"; +import { ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime"; import { getReaper } from "../reaper/reaper"; import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; import { @@ -47,18 +47,36 @@ export class StartedGenericContainer implements StartedTestContainer { }); } + /** + * Construct the command(s) to apply changes to the container before committing it to an image. + */ + private async getContainerCommitChangeCommands(options: { + deleteOnExit: boolean; + changes?: string[]; + client: ContainerRuntimeClient; + }): Promise { + const { deleteOnExit, client } = options; + const changes = options.changes || []; + if (deleteOnExit) { + let sessionId = this.getLabels()[LABEL_TESTCONTAINERS_SESSION_ID]; + if (!sessionId) { + sessionId = await getReaper(client).then((reaper) => reaper.sessionId); + } + changes.push(`LABEL ${LABEL_TESTCONTAINERS_SESSION_ID}=${sessionId}`); + } else if (!deleteOnExit && this.getLabels()[LABEL_TESTCONTAINERS_SESSION_ID]) { + // By default, commit will save the existing labels (including the session ID) to the new image. If + // deleteOnExit is false, we need to remove the session ID label. + changes.push(`LABEL ${LABEL_TESTCONTAINERS_SESSION_ID}=`); + } + return changes.filter(Boolean).join("\n"); + } + public async commit(options: ContainerCommitOptions): Promise { log.info(`Committing container image...`, { containerId: this.container.id }); const client = await getContainerRuntimeClient(); - const { deleteOnExit = true, ...commitOpts } = options; - const image = await client.image.inspect(ImageName.fromString(this.inspectResult.Config.Image)); - const labels = { ...image.Config.Labels, ...commitOpts.labels }; - if (deleteOnExit && !labels[LABEL_TESTCONTAINERS_SESSION_ID]) { - const reaper = await getReaper(client); - labels[LABEL_TESTCONTAINERS_SESSION_ID] = reaper.sessionId; - } - const commitOptions = { ...commitOpts, labels }; - const imageId = await client.container.commit(this.container, commitOptions); + const { deleteOnExit = true, changes, ...commitOpts } = options; + const changeCommands = await this.getContainerCommitChangeCommands({ deleteOnExit, changes, client }); + const imageId = await client.container.commit(this.container, { ...commitOpts, changes: changeCommands }); log.info(`Committed container image (Image ID: ${imageId}`, { containerId: this.container.id }); return imageId; } diff --git a/packages/testcontainers/src/types.ts b/packages/testcontainers/src/types.ts index e45ec1c12..622f422a7 100644 --- a/packages/testcontainers/src/types.ts +++ b/packages/testcontainers/src/types.ts @@ -86,7 +86,12 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment } export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number }; -export type ContainerCommitOptions = CommitOptions & { deleteOnExit?: boolean }; +/** + * Options for committing a container to an image; see https://docs.docker.com/engine/reference/commandline/commit/ + * @param deleteOnExit If true, the image will be cleaned up by reaper on exit + * @param changes Additional changes to apply to the container before committing it to an image (e.g. ["ENV TEST=true"]) + */ +export type ContainerCommitOptions = Omit & { deleteOnExit?: boolean; changes?: string[] }; export type HealthCheckStatus = "none" | "starting" | "unhealthy" | "healthy"; From 8f112a8fe938d4138e1d77d1a4ca69a8f48fa8ad Mon Sep 17 00:00:00 2001 From: Sean McRoskey Date: Thu, 13 Mar 2025 10:57:48 -0700 Subject: [PATCH 6/7] address PR feedback --- docs/features/containers.md | 13 +++++++++++++ .../clients/container/container-client.ts | 4 ++-- .../clients/container/docker-container-client.ts | 6 +++--- .../container-runtime/clients/container/types.ts | 2 +- .../clients/image/docker-image-client.ts | 4 ++-- .../src/container-runtime/index.ts | 2 +- .../abstract-started-container.ts | 12 ++---------- .../generic-container-commit.test.ts | 6 +++++- .../started-generic-container.ts | 16 +++------------- packages/testcontainers/src/index.ts | 7 ++++++- packages/testcontainers/src/test-container.ts | 4 ++-- packages/testcontainers/src/types.ts | 4 ++-- 12 files changed, 42 insertions(+), 38 deletions(-) diff --git a/docs/features/containers.md b/docs/features/containers.md index b9b1e0b6d..2b45a32b8 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -364,6 +364,19 @@ const container = await new GenericContainer("alpine").start(); // Do something with the container await container.exec(["sh", "-c", `echo 'hello world' > /hello-world.txt`]); // Commit the container to an image +const newImageId = await container.commit({ repo: "my-repo", tag: "my-tag" }); +// Use this image in a new container +const containerFromCommit = await new GenericContainer(newImageId).start(); +``` + +By default, the image inherits the behavior of being marked for cleanup on exit. You can override this behavior using +the `deleteOnExit` option: + +```javascript +const container = await new GenericContainer("alpine").start(); +// Do something with the container +await container.exec(["sh", "-c", `echo 'hello world' > /hello-world.txt`]); +// Commit the container to an image; committed image will not be cleaned up on exit const newImageId = await container.commit({ repo: "my-repo", tag: "my-tag", deleteOnExit: false }); ``` 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 9674491d7..3f1460fe9 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 { CommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types"; +import { ContainerCommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types"; export interface ContainerClient { dockerode: Dockerode; @@ -31,7 +31,7 @@ export interface ContainerClient { logs(container: Container, opts?: ContainerLogsOptions): Promise; exec(container: Container, command: string[], opts?: Partial): Promise; restart(container: Container, opts?: { timeout: number }): Promise; - commit(container: Container, opts: CommitOptions): Promise; + commit(container: Container, opts: ContainerCommitOptions): Promise; events(container: Container, eventNames: string[]): 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 3af4b1fa0..1b855642b 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 @@ -11,7 +11,7 @@ import { IncomingMessage } from "http"; import { PassThrough, Readable } from "stream"; import { execLog, log, streamToString } from "../../../common"; import { ContainerClient } from "./container-client"; -import { CommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types"; +import { ContainerCommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types"; export class DockerContainerClient implements ContainerClient { constructor(public readonly dockerode: Dockerode) {} @@ -264,11 +264,11 @@ export class DockerContainerClient implements ContainerClient { } } - async commit(container: Container, opts: CommitOptions): Promise { + async commit(container: Container, opts: ContainerCommitOptions): Promise { try { log.debug(`Committing container...`, { containerId: container.id }); const { Id: imageId } = await container.commit(opts); - log.debug(`Committed container to image (Image ID: ${imageId})`, { containerId: container.id }); + log.debug(`Committed container to image "${imageId}"`, { containerId: container.id }); return imageId; } catch (err) { log.error(`Failed to commit container: ${err}`, { containerId: container.id }); diff --git a/packages/testcontainers/src/container-runtime/clients/container/types.ts b/packages/testcontainers/src/container-runtime/clients/container/types.ts index 4af0265c7..6d2d61844 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/types.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/types.ts @@ -4,7 +4,7 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment; export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number }; -export type CommitOptions = { +export type ContainerCommitOptions = { repo: string; tag: string; comment?: string; diff --git a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts index b65590978..0578f2855 100644 --- a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts @@ -67,9 +67,9 @@ export class DockerImageClient implements ImageClient { async inspect(imageName: ImageName): Promise { try { - log.debug(`Inspecting image "${imageName.string}"...`); + log.debug(`Inspecting image: "${imageName.string}"...`); const imageInfo = await this.dockerode.getImage(imageName.string).inspect(); - log.debug(`Retrieved image info "${imageName.string}"`); + log.debug(`Inspected image: "${imageName.string}"`); return imageInfo; } catch (err) { log.debug(`Failed to inspect image "${imageName.string}"`); diff --git a/packages/testcontainers/src/container-runtime/index.ts b/packages/testcontainers/src/container-runtime/index.ts index 659b1a020..19a6a5507 100644 --- a/packages/testcontainers/src/container-runtime/index.ts +++ b/packages/testcontainers/src/container-runtime/index.ts @@ -2,6 +2,6 @@ export { getAuthConfig } from "./auth/get-auth-config"; export { ContainerRuntimeClient, getContainerRuntimeClient } from "./clients/client"; export { parseComposeContainerName } from "./clients/compose/parse-compose-container-name"; export { ComposeDownOptions, ComposeOptions } from "./clients/compose/types"; -export { CommitOptions } from "./clients/container/types"; +export { ContainerCommitOptions } from "./clients/container/types"; export { HostIp } from "./clients/types"; export { ImageName } from "./image-name"; diff --git a/packages/testcontainers/src/generic-container/abstract-started-container.ts b/packages/testcontainers/src/generic-container/abstract-started-container.ts index 372652f43..43d09232f 100644 --- a/packages/testcontainers/src/generic-container/abstract-started-container.ts +++ b/packages/testcontainers/src/generic-container/abstract-started-container.ts @@ -1,14 +1,6 @@ import { Readable } from "stream"; import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; -import { - ContainerCommitOptions, - ContentToCopy, - DirectoryToCopy, - ExecOptions, - ExecResult, - FileToCopy, - Labels, -} from "../types"; +import { CommitOptions, ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types"; export class AbstractStartedContainer implements StartedTestContainer { constructor(protected readonly startedTestContainer: StartedTestContainer) {} @@ -35,7 +27,7 @@ export class AbstractStartedContainer implements StartedTestContainer { return this.startedTestContainer.restart(options); } - public async commit(options: ContainerCommitOptions): Promise { + public async commit(options: CommitOptions): Promise { return this.startedTestContainer.commit(options); } diff --git a/packages/testcontainers/src/generic-container/generic-container-commit.test.ts b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts index 47c859344..21ab42a2c 100644 --- a/packages/testcontainers/src/generic-container/generic-container-commit.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts @@ -1,5 +1,7 @@ import { expect } from "vitest"; import { RandomUuid } from "../common"; +import { getContainerRuntimeClient } from "../container-runtime"; +import { getReaper } from "../reaper/reaper"; import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; import { deleteImageByName, getImageInfo } from "../utils/test-helper"; import { GenericContainer } from "./generic-container"; @@ -57,7 +59,9 @@ describe("GenericContainer commit", { timeout: 180_000 }, () => { // Verify session ID label is present const imageInfo = await getImageInfo(imageId); - expect(imageInfo.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBeDefined(); + const client = await getContainerRuntimeClient(); + const reaper = await getReaper(client); + expect(imageInfo.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBe(reaper.sessionId); await container.stop(); }); diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index fc705927f..51e4e25d5 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -6,15 +6,7 @@ import { containerLog, log } from "../common"; import { ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime"; import { getReaper } from "../reaper/reaper"; import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; -import { - ContainerCommitOptions, - ContentToCopy, - DirectoryToCopy, - ExecOptions, - ExecResult, - FileToCopy, - Labels, -} from "../types"; +import { CommitOptions, ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types"; import { BoundPorts } from "../utils/bound-ports"; import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; import { mapInspectResult } from "../utils/map-inspect-result"; @@ -68,16 +60,14 @@ export class StartedGenericContainer implements StartedTestContainer { // deleteOnExit is false, we need to remove the session ID label. changes.push(`LABEL ${LABEL_TESTCONTAINERS_SESSION_ID}=`); } - return changes.filter(Boolean).join("\n"); + return changes.join("\n"); } - public async commit(options: ContainerCommitOptions): Promise { - log.info(`Committing container image...`, { containerId: this.container.id }); + public async commit(options: CommitOptions): Promise { const client = await getContainerRuntimeClient(); const { deleteOnExit = true, changes, ...commitOpts } = options; const changeCommands = await this.getContainerCommitChangeCommands({ deleteOnExit, changes, client }); const imageId = await client.container.commit(this.container, { ...commitOpts, changes: changeCommands }); - log.info(`Committed container image (Image ID: ${imageId}`, { containerId: this.container.id }); return imageId; } diff --git a/packages/testcontainers/src/index.ts b/packages/testcontainers/src/index.ts index 6e88a4642..cb82aa93f 100644 --- a/packages/testcontainers/src/index.ts +++ b/packages/testcontainers/src/index.ts @@ -1,5 +1,10 @@ export { IntervalRetry, log, RandomUuid, Retry, Uuid } from "./common"; -export { CommitOptions, ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "./container-runtime"; +export { + ContainerCommitOptions, + ContainerRuntimeClient, + getContainerRuntimeClient, + ImageName, +} from "./container-runtime"; export { DockerComposeEnvironment } from "./docker-compose-environment/docker-compose-environment"; export { DownedDockerComposeEnvironment } from "./docker-compose-environment/downed-docker-compose-environment"; export { StartedDockerComposeEnvironment } from "./docker-compose-environment/started-docker-compose-environment"; diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index 2e7091640..82a12a25f 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -2,7 +2,7 @@ import { Readable } from "stream"; import { StartedNetwork } from "./network/network"; import { BindMount, - ContainerCommitOptions, + CommitOptions, ContentToCopy, DirectoryToCopy, Environment, @@ -65,7 +65,7 @@ export interface StopOptions { export interface StartedTestContainer { stop(options?: Partial): Promise; restart(options?: Partial): Promise; - commit(options: ContainerCommitOptions): Promise; + commit(options: CommitOptions): Promise; getHost(): string; getHostname(): string; getFirstMappedPort(): number; diff --git a/packages/testcontainers/src/types.ts b/packages/testcontainers/src/types.ts index 622f422a7..0ca37bd86 100644 --- a/packages/testcontainers/src/types.ts +++ b/packages/testcontainers/src/types.ts @@ -1,5 +1,5 @@ import { Readable } from "stream"; -import { CommitOptions } from "./container-runtime"; +import { ContainerCommitOptions } from "./container-runtime"; export type InspectResult = { name: string; @@ -91,7 +91,7 @@ export type ExecResult = { output: string; stdout: string; stderr: string; exitC * @param deleteOnExit If true, the image will be cleaned up by reaper on exit * @param changes Additional changes to apply to the container before committing it to an image (e.g. ["ENV TEST=true"]) */ -export type ContainerCommitOptions = Omit & { deleteOnExit?: boolean; changes?: string[] }; +export type CommitOptions = Omit & { deleteOnExit?: boolean; changes?: string[] }; export type HealthCheckStatus = "none" | "starting" | "unhealthy" | "healthy"; From 5a4b78e576ceef0ae0cb543cfd54bf5d3c126f15 Mon Sep 17 00:00:00 2001 From: Sean McRoskey Date: Thu, 13 Mar 2025 17:28:10 -0700 Subject: [PATCH 7/7] export CommitOptions --- packages/testcontainers/src/container-runtime/index.ts | 1 - packages/testcontainers/src/index.ts | 9 ++------- packages/testcontainers/src/types.ts | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/testcontainers/src/container-runtime/index.ts b/packages/testcontainers/src/container-runtime/index.ts index 19a6a5507..6717aaec5 100644 --- a/packages/testcontainers/src/container-runtime/index.ts +++ b/packages/testcontainers/src/container-runtime/index.ts @@ -2,6 +2,5 @@ export { getAuthConfig } from "./auth/get-auth-config"; export { ContainerRuntimeClient, getContainerRuntimeClient } from "./clients/client"; export { parseComposeContainerName } from "./clients/compose/parse-compose-container-name"; export { ComposeDownOptions, ComposeOptions } from "./clients/compose/types"; -export { ContainerCommitOptions } from "./clients/container/types"; export { HostIp } from "./clients/types"; export { ImageName } from "./image-name"; diff --git a/packages/testcontainers/src/index.ts b/packages/testcontainers/src/index.ts index cb82aa93f..79c5506d0 100644 --- a/packages/testcontainers/src/index.ts +++ b/packages/testcontainers/src/index.ts @@ -1,10 +1,5 @@ export { IntervalRetry, log, RandomUuid, Retry, Uuid } from "./common"; -export { - ContainerCommitOptions, - ContainerRuntimeClient, - getContainerRuntimeClient, - ImageName, -} from "./container-runtime"; +export { ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "./container-runtime"; export { DockerComposeEnvironment } from "./docker-compose-environment/docker-compose-environment"; export { DownedDockerComposeEnvironment } from "./docker-compose-environment/downed-docker-compose-environment"; export { StartedDockerComposeEnvironment } from "./docker-compose-environment/started-docker-compose-environment"; @@ -23,7 +18,7 @@ export { TestContainer, } from "./test-container"; export { TestContainers } from "./test-containers"; -export { Content, ExecOptions, ExecResult, InspectResult } from "./types"; +export { CommitOptions, Content, ExecOptions, ExecResult, InspectResult } from "./types"; export { BoundPorts } from "./utils/bound-ports"; export { LABEL_TESTCONTAINERS_SESSION_ID } from "./utils/labels"; export { getContainerPort, hasHostBinding, PortWithBinding, PortWithOptionalBinding } from "./utils/port"; diff --git a/packages/testcontainers/src/types.ts b/packages/testcontainers/src/types.ts index 0ca37bd86..5d078eb73 100644 --- a/packages/testcontainers/src/types.ts +++ b/packages/testcontainers/src/types.ts @@ -1,5 +1,5 @@ import { Readable } from "stream"; -import { ContainerCommitOptions } from "./container-runtime"; +import { ContainerCommitOptions } from "./container-runtime/clients/container/types"; export type InspectResult = { name: string;