diff --git a/docs/features/containers.md b/docs/features/containers.md index e4c8caffd..2b45a32b8 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -357,6 +357,29 @@ 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 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 }); +``` + ## 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..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 { ContainerStatus, ExecOptions, ExecResult } from "./types"; +import { ContainerCommitOptions, 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: 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 e5087daf5..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 { ContainerStatus, ExecOptions, ExecResult } from "./types"; +import { ContainerCommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types"; export class DockerContainerClient implements ContainerClient { constructor(public readonly dockerode: Dockerode) {} @@ -264,6 +264,18 @@ export class DockerContainerClient implements ContainerClient { } } + 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 "${imageId}"`, { containerId: container.id }); + return imageId; + } 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..6d2d61844 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/types.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/types.ts @@ -4,6 +4,15 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment; export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number }; +export type ContainerCommitOptions = { + repo: string; + tag: string; + comment?: string; + author?: string; + pause?: boolean; + changes?: string; +}; + 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/container-runtime/clients/image/docker-image-client.ts b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts index 9dd630934..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 @@ -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(`Inspected image: "${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 d4b7f40f1..43d09232f 100644 --- a/packages/testcontainers/src/generic-container/abstract-started-container.ts +++ b/packages/testcontainers/src/generic-container/abstract-started-container.ts @@ -1,6 +1,6 @@ import { Readable } from "stream"; import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; -import { 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) {} @@ -27,6 +27,10 @@ export class AbstractStartedContainer implements StartedTestContainer { return this.startedTestContainer.restart(options); } + public async commit(options: CommitOptions): 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..21ab42a2c --- /dev/null +++ b/packages/testcontainers/src/generic-container/generic-container-commit.test.ts @@ -0,0 +1,91 @@ +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"; + +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}`).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 + const imageId = await container.commit({ + repo: imageName, + tag: newImageTag, + author: testAuthor, + comment: testComment, + }); + + // Verify image metadata is set + 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(imageId).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(); + }); + + 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); + const client = await getContainerRuntimeClient(); + const reaper = await getReaper(client); + expect(imageInfo.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBe(reaper.sessionId); + + await container.stop(); + }); + + it("should not add session ID label when deleteOnExit is false", async () => { + const newImageTag = `test-commit-${new RandomUuid().nextUuid()}`; + const container = await new GenericContainer(`${imageName}:${imageVersion}`).withExposedPorts(8080).start(); + + // Commit with deleteOnExit false + const imageId = await container.commit({ + repo: imageName, + tag: newImageTag, + changes: ["LABEL test=test", "ENV test=test"], + deleteOnExit: false, + }); + + const imageInfo = await getImageInfo(imageId); + // 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 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 b412f6cfd..51e4e25d5 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -3,10 +3,12 @@ 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 { ContainerRuntimeClient, getContainerRuntimeClient } 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 { 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"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; @@ -37,6 +39,38 @@ 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.join("\n"); + } + + 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 }); + return imageId; + } + protected containerIsStopped?(): Promise; public async restart(options: Partial = {}): Promise { diff --git a/packages/testcontainers/src/index.ts b/packages/testcontainers/src/index.ts index 47c9be0e2..79c5506d0 100644 --- a/packages/testcontainers/src/index.ts +++ b/packages/testcontainers/src/index.ts @@ -18,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/test-container.ts b/packages/testcontainers/src/test-container.ts index 56a47e271..82a12a25f 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -2,6 +2,7 @@ import { Readable } from "stream"; import { StartedNetwork } from "./network/network"; import { BindMount, + CommitOptions, ContentToCopy, DirectoryToCopy, Environment, @@ -43,6 +44,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; @@ -63,6 +65,7 @@ export interface StopOptions { export interface StartedTestContainer { stop(options?: Partial): Promise; restart(options?: Partial): 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 ad8eb865e..5d078eb73 100644 --- a/packages/testcontainers/src/types.ts +++ b/packages/testcontainers/src/types.ts @@ -1,4 +1,5 @@ import { Readable } from "stream"; +import { ContainerCommitOptions } from "./container-runtime/clients/container/types"; export type InspectResult = { name: string; @@ -85,6 +86,13 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment } export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number }; +/** + * 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 CommitOptions = Omit & { deleteOnExit?: boolean; changes?: string[] }; + export type HealthCheckStatus = "none" | "starting" | "unhealthy" | "healthy"; export type NetworkSettings = { diff --git a/packages/testcontainers/src/utils/test-helper.ts b/packages/testcontainers/src/utils/test-helper.ts index 1a0aeb26e..d99a55358 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 {