Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/features/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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 { CommitOptions, 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?: CommitOptions): Promise<void>;
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 { CommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types";

export class DockerContainerClient implements ContainerClient {
constructor(public readonly dockerode: Dockerode) {}
Expand Down Expand Up @@ -264,6 +264,17 @@ export class DockerContainerClient implements ContainerClient {
}
}

async commit(container: Container, opts?: CommitOptions): Promise<void> {
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<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,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];
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -27,6 +33,10 @@ export class AbstractStartedContainer implements StartedTestContainer {
return this.startedTestContainer.restart(options);
}

public async commit(options?: Partial<CommitOptions>): Promise<void> {
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,52 @@
import { RandomUuid } from "../common";
import { deleteImageByName, getImageInfo, getImageLabelsByName } from "../utils/test-helper";

Check warning on line 2 in packages/testcontainers/src/generic-container/generic-container-commit.test.ts

View workflow job for this annotation

GitHub Actions / Lint (testcontainers)

'getImageLabelsByName' is defined but never used

Check failure on line 2 in packages/testcontainers/src/generic-container/generic-container-commit.test.ts

View workflow job for this annotation

GitHub Actions / Lint (testcontainers)

Delete `,·getImageLabelsByName`
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}`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
import { Readable } from "stream";
import { containerLog, log } from "../common";
import { getContainerRuntimeClient } from "../container-runtime";
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";

Check failure on line 7 in packages/testcontainers/src/generic-container/started-generic-container.ts

View workflow job for this annotation

GitHub Actions / Lint (testcontainers)

Insert `CommitOptions·}·from·"../container-runtime/clients/container/types";⏎import·{·`
import { ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types";
import { BoundPorts } from "../utils/bound-ports";
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";

Check failure on line 13 in packages/testcontainers/src/generic-container/started-generic-container.ts

View workflow job for this annotation

GitHub Actions / Lint (testcontainers)

Delete `";⏎import·{·CommitOptions·}·from·"../container-runtime/clients/container/types`
import { CommitOptions } from "../container-runtime/clients/container/types";

export class StartedGenericContainer implements StartedTestContainer {
private stoppedContainer?: StoppedTestContainer;
Expand All @@ -37,6 +38,13 @@
});
}

public async commit(options?: Partial<CommitOptions>): Promise<void> {
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<void>;

public async restart(options: Partial<RestartOptions> = {}): Promise<void> {
Expand Down
10 changes: 10 additions & 0 deletions packages/testcontainers/src/test-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<StopOptions>): Promise<StoppedTestContainer>;
restart(options?: Partial<RestartOptions>): Promise<void>;
commit(options?: Partial<CommitOptions>): Promise<void>;
getHost(): string;
getHostname(): string;
getFirstMappedPort(): number;
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 @@
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;
}

Check failure on line 58 in packages/testcontainers/src/utils/test-helper.ts

View workflow job for this annotation

GitHub Actions / Lint (testcontainers)

Insert `;`

export const checkImageExists = async (imageName: string): Promise<boolean> => {
const dockerode = (await getContainerRuntimeClient()).container.dockerode;
try {
Expand Down
Loading