Skip to content

Commit 51ee497

Browse files
committed
feat: add support for container.commit() [884]
1 parent 5817d69 commit 51ee497

File tree

9 files changed

+115
-4
lines changed

9 files changed

+115
-4
lines changed

docs/features/containers.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,16 @@ const container = await new GenericContainer("alpine").start();
357357
await container.restart();
358358
```
359359

360+
## Committing a container to an image
361+
362+
```javascript
363+
const container = await new GenericContainer("alpine").start();
364+
// Do something with the container
365+
await container.exec(["sh", "-c", `echo 'hello world' > /hello-world.txt`]);
366+
// Commit the container to an image
367+
const newImage = await container.commit({ repository: "my-repo", tag: "my-tag" });
368+
```
369+
360370
## Reusing a container
361371

362372
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.

packages/testcontainers/src/container-runtime/clients/container/container-client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Dockerode, {
77
Network,
88
} from "dockerode";
99
import { Readable } from "stream";
10-
import { ContainerStatus, ExecOptions, ExecResult } from "./types";
10+
import { CommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types";
1111

1212
export interface ContainerClient {
1313
dockerode: Dockerode;
@@ -31,6 +31,7 @@ export interface ContainerClient {
3131
logs(container: Container, opts?: ContainerLogsOptions): Promise<Readable>;
3232
exec(container: Container, command: string[], opts?: Partial<ExecOptions>): Promise<ExecResult>;
3333
restart(container: Container, opts?: { timeout: number }): Promise<void>;
34+
commit(container: Container, opts?: CommitOptions): Promise<void>;
3435
events(container: Container, eventNames: string[]): Promise<Readable>;
3536
remove(container: Container, opts?: { removeVolumes: boolean }): Promise<void>;
3637
connectToNetwork(container: Container, network: Network, networkAliases: string[]): Promise<void>;

packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { IncomingMessage } from "http";
1111
import { PassThrough, Readable } from "stream";
1212
import { execLog, log, streamToString } from "../../../common";
1313
import { ContainerClient } from "./container-client";
14-
import { ContainerStatus, ExecOptions, ExecResult } from "./types";
14+
import { CommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types";
1515

1616
export class DockerContainerClient implements ContainerClient {
1717
constructor(public readonly dockerode: Dockerode) {}
@@ -264,6 +264,17 @@ export class DockerContainerClient implements ContainerClient {
264264
}
265265
}
266266

267+
async commit(container: Container, opts?: CommitOptions): Promise<void> {
268+
try {
269+
log.debug(`Committing container...`, { containerId: container.id });
270+
await container.commit(opts);
271+
log.debug(`Committed container image`, { containerId: container.id });
272+
} catch (err) {
273+
log.error(`Failed to commit container: ${err}`, { containerId: container.id });
274+
throw err;
275+
}
276+
}
277+
267278
async remove(container: Container, opts?: { removeVolumes: boolean }): Promise<void> {
268279
try {
269280
log.debug(`Removing container...`, { containerId: container.id });

packages/testcontainers/src/container-runtime/clients/container/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment;
44

55
export type ExecResult = { output: string; stdout: string; stderr: string; exitCode: number };
66

7+
export type CommitOptions = { repo?: string; tag?: string; comment?: string; author?: string; pause?: boolean };
8+
79
export const CONTAINER_STATUSES = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const;
810

911
export type ContainerStatus = (typeof CONTAINER_STATUSES)[number];

packages/testcontainers/src/generic-container/abstract-started-container.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { Readable } from "stream";
2-
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";
2+
import {
3+
CommitOptions,
4+
RestartOptions,
5+
StartedTestContainer,
6+
StopOptions,
7+
StoppedTestContainer,
8+
} from "../test-container";
39
import { ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types";
410

511
export class AbstractStartedContainer implements StartedTestContainer {
@@ -27,6 +33,10 @@ export class AbstractStartedContainer implements StartedTestContainer {
2733
return this.startedTestContainer.restart(options);
2834
}
2935

36+
public async commit(options?: Partial<CommitOptions>): Promise<void> {
37+
return this.startedTestContainer.commit(options);
38+
}
39+
3040
public getHost(): string {
3141
return this.startedTestContainer.getHost();
3242
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { RandomUuid } from "../common";
2+
import { deleteImageByName, getImageInfo, getImageLabelsByName } from "../utils/test-helper";
3+
import { GenericContainer } from "./generic-container";
4+
5+
describe("GenericContainer commit", { timeout: 180_000 }, () => {
6+
const imageName = "cristianrgreco/testcontainer";
7+
const imageVersion = "1.1.14";
8+
9+
it("should commit container changes to a new image", async () => {
10+
const testContent = "test content";
11+
const newImageTag = `test-commit-${new RandomUuid().nextUuid()}`;
12+
const testAuthor = "test-author";
13+
const testComment = "test-comment";
14+
15+
// Start original container and make a change
16+
const container = await new GenericContainer(`${imageName}:${imageVersion}`)
17+
.withName(`container-${new RandomUuid().nextUuid()}`)
18+
.withExposedPorts(8080)
19+
.start();
20+
21+
// Make a change to the container
22+
await container.exec(["sh", "-c", `echo '${testContent}' > /test-file.txt`]);
23+
24+
// Commit the changes to a new image
25+
await container.commit({
26+
repo: imageName,
27+
tag: newImageTag,
28+
author: testAuthor,
29+
comment: testComment,
30+
});
31+
32+
// Verify image author and comment are set
33+
const imageInfo = await getImageInfo(`${imageName}:${newImageTag}`);
34+
expect(imageInfo.Author).toBe(testAuthor);
35+
expect(imageInfo.Comment).toBe(testComment);
36+
37+
// Start a new container from the committed image
38+
const newContainer = await new GenericContainer(`${imageName}:${newImageTag}`)
39+
.withName(`container-${new RandomUuid().nextUuid()}`)
40+
.withExposedPorts(8080)
41+
.start();
42+
43+
// Verify the changes exist in the new container
44+
const result = await newContainer.exec(["cat", "/test-file.txt"]);
45+
expect(result.output.trim()).toBe(testContent);
46+
47+
// Cleanup
48+
await container.stop();
49+
await newContainer.stop();
50+
await deleteImageByName(`${imageName}:${newImageTag}`);
51+
});
52+
});

packages/testcontainers/src/generic-container/started-generic-container.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { mapInspectResult } from "../utils/map-inspect-result";
1111
import { waitForContainer } from "../wait-strategies/wait-for-container";
1212
import { WaitStrategy } from "../wait-strategies/wait-strategy";
1313
import { StoppedGenericContainer } from "./stopped-generic-container";
14+
import { CommitOptions } from "../container-runtime/clients/container/types";
1415

1516
export class StartedGenericContainer implements StartedTestContainer {
1617
private stoppedContainer?: StoppedTestContainer;
@@ -37,6 +38,13 @@ export class StartedGenericContainer implements StartedTestContainer {
3738
});
3839
}
3940

41+
public async commit(options?: Partial<CommitOptions>): Promise<void> {
42+
log.info(`Committing container image...`, { containerId: this.container.id });
43+
const client = await getContainerRuntimeClient();
44+
await client.container.commit(this.container, options);
45+
log.info(`Committed container image`, { containerId: this.container.id });
46+
}
47+
4048
protected containerIsStopped?(): Promise<void>;
4149

4250
public async restart(options: Partial<RestartOptions> = {}): Promise<void> {

packages/testcontainers/src/test-container.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export interface TestContainer {
4343
withCopyFilesToContainer(filesToCopy: FileToCopy[]): this;
4444
withCopyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): this;
4545
withCopyContentToContainer(contentsToCopy: ContentToCopy[]): this;
46+
4647
withWorkingDir(workingDir: string): this;
4748
withResourcesQuota(resourcesQuota: ResourcesQuota): this;
4849
withSharedMemorySize(bytes: number): this;
@@ -60,9 +61,18 @@ export interface StopOptions {
6061
removeVolumes: boolean;
6162
}
6263

64+
export interface CommitOptions {
65+
repo?: string;
66+
tag?: string;
67+
comment?: string;
68+
author?: string;
69+
pause?: boolean;
70+
}
71+
6372
export interface StartedTestContainer {
6473
stop(options?: Partial<StopOptions>): Promise<StoppedTestContainer>;
6574
restart(options?: Partial<RestartOptions>): Promise<void>;
75+
commit(options?: Partial<CommitOptions>): Promise<void>;
6676
getHost(): string;
6777
getHostname(): string;
6878
getFirstMappedPort(): number;

packages/testcontainers/src/utils/test-helper.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GetEventsOptions } from "dockerode";
1+
import { GetEventsOptions, ImageInspectInfo } from "dockerode";
22
import { Readable } from "stream";
33
import { Agent } from "undici";
44
import { IntervalRetry } from "../common";
@@ -50,6 +50,13 @@ export const getContainerIds = async (): Promise<string[]> => {
5050
return containers.map((container) => container.Id);
5151
};
5252

53+
export const getImageInfo = async (imageName: string): Promise<ImageInspectInfo> => {
54+
const dockerode = (await getContainerRuntimeClient()).container.dockerode;
55+
const image = dockerode.getImage(imageName);
56+
const imageInfo = await image.inspect();
57+
return imageInfo;
58+
}
59+
5360
export const checkImageExists = async (imageName: string): Promise<boolean> => {
5461
const dockerode = (await getContainerRuntimeClient()).container.dockerode;
5562
try {

0 commit comments

Comments
 (0)