Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/features/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Dockerode, {
Network,
} from "dockerode";
import { Readable } from "stream";
import { ContainerStatus, ExecOptions, ExecResult } from "./types";
import { ContainerCommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types";

export interface ContainerClient {
dockerode: Dockerode;
Expand All @@ -31,6 +31,7 @@ export interface ContainerClient {
logs(container: Container, opts?: ContainerLogsOptions): Promise<Readable>;
exec(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecResult>;
restart(container: Container, opts?: { timeout: number }): Promise<void>;
commit(container: Container, opts: ContainerCommitOptions): Promise<string>;
events(container: Container, eventNames: string[]): Promise<Readable>;
remove(container: Container, opts?: { removeVolumes: boolean }): Promise<void>;
connectToNetwork(container: Container, network: Network, networkAliases: string[]): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down Expand Up @@ -264,6 +264,18 @@ export class DockerContainerClient implements ContainerClient {
}
}

async commit(container: Container, opts: ContainerCommitOptions): Promise<string> {
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<void> {
try {
log.debug(`Removing container...`, { containerId: container.id });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -65,6 +65,18 @@ export class DockerImageClient implements ImageClient {
return (aPath: string) => !filter(aPath);
}

async inspect(imageName: ImageName): Promise<ImageInspectInfo> {
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<boolean> {
return this.imageExistsLock.acquire(imageName.string, async () => {
if (this.existingImages.has(imageName.string)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>;
pull(imageName: ImageName, opts?: { force: boolean; platform: string | undefined }): Promise<void>;
inspect(imageName: ImageName): Promise<ImageInspectInfo>;
exists(imageName: ImageName): Promise<boolean>;
}
Original file line number Diff line number Diff line change
@@ -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) {}
Expand All @@ -27,6 +27,10 @@ export class AbstractStartedContainer implements StartedTestContainer {
return this.startedTestContainer.restart(options);
}

public async commit(options: CommitOptions): Promise<string> {
return this.startedTestContainer.commit(options);
}

public getHost(): string {
return this.startedTestContainer.getHost();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string> {
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<string> {
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<void>;

public async restart(options: Partial<RestartOptions> = {}): Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion packages/testcontainers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions packages/testcontainers/src/test-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Readable } from "stream";
import { StartedNetwork } from "./network/network";
import {
BindMount,
CommitOptions,
ContentToCopy,
DirectoryToCopy,
Environment,
Expand Down Expand Up @@ -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;
Expand All @@ -63,6 +65,7 @@ export interface StopOptions {
export interface StartedTestContainer {
stop(options?: Partial<StopOptions>): Promise<StoppedTestContainer>;
restart(options?: Partial<RestartOptions>): Promise<void>;
commit(options: CommitOptions): Promise<string>;
getHost(): string;
getHostname(): string;
getFirstMappedPort(): number;
Expand Down
8 changes: 8 additions & 0 deletions packages/testcontainers/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Readable } from "stream";
import { ContainerCommitOptions } from "./container-runtime/clients/container/types";

export type InspectResult = {
name: string;
Expand Down Expand Up @@ -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<ContainerCommitOptions, "changes"> & { deleteOnExit?: boolean; changes?: string[] };

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

export type NetworkSettings = {
Expand Down
9 changes: 8 additions & 1 deletion packages/testcontainers/src/utils/test-helper.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -50,6 +50,13 @@ export const getContainerIds = async (): Promise<string[]> => {
return containers.map((container) => container.Id);
};

export const getImageInfo = async (imageName: string): Promise<ImageInspectInfo> => {
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<boolean> => {
const dockerode = (await getContainerRuntimeClient()).container.dockerode;
try {
Expand Down