Skip to content

Commit 3574498

Browse files
authored
Add support for container commit (#923)
1 parent 5817d69 commit 3574498

File tree

13 files changed

+214
-9
lines changed

13 files changed

+214
-9
lines changed

docs/features/containers.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,29 @@ 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 newImageId = await container.commit({ repo: "my-repo", tag: "my-tag" });
368+
// Use this image in a new container
369+
const containerFromCommit = await new GenericContainer(newImageId).start();
370+
```
371+
372+
By default, the image inherits the behavior of being marked for cleanup on exit. You can override this behavior using
373+
the `deleteOnExit` option:
374+
375+
```javascript
376+
const container = await new GenericContainer("alpine").start();
377+
// Do something with the container
378+
await container.exec(["sh", "-c", `echo 'hello world' > /hello-world.txt`]);
379+
// Commit the container to an image; committed image will not be cleaned up on exit
380+
const newImageId = await container.commit({ repo: "my-repo", tag: "my-tag", deleteOnExit: false });
381+
```
382+
360383
## Reusing a container
361384

362385
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 { ContainerCommitOptions, 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: ContainerCommitOptions): Promise<string>;
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: 13 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 { ContainerCommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types";
1515

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

267+
async commit(container: Container, opts: ContainerCommitOptions): Promise<string> {
268+
try {
269+
log.debug(`Committing container...`, { containerId: container.id });
270+
const { Id: imageId } = await container.commit(opts);
271+
log.debug(`Committed container to image "${imageId}"`, { containerId: container.id });
272+
return imageId;
273+
} catch (err) {
274+
log.error(`Failed to commit container: ${err}`, { containerId: container.id });
275+
throw err;
276+
}
277+
}
278+
267279
async remove(container: Container, opts?: { removeVolumes: boolean }): Promise<void> {
268280
try {
269281
log.debug(`Removing container...`, { containerId: container.id });

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ 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 ContainerCommitOptions = {
8+
repo: string;
9+
tag: string;
10+
comment?: string;
11+
author?: string;
12+
pause?: boolean;
13+
changes?: string;
14+
};
15+
716
export const CONTAINER_STATUSES = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const;
817

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

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import dockerIgnore from "@balena/dockerignore";
22
import AsyncLock from "async-lock";
33
import byline from "byline";
4-
import Dockerode, { ImageBuildOptions } from "dockerode";
4+
import Dockerode, { ImageBuildOptions, ImageInspectInfo } from "dockerode";
55
import { existsSync, promises as fs } from "fs";
66
import path from "path";
77
import tar from "tar-fs";
@@ -65,6 +65,18 @@ export class DockerImageClient implements ImageClient {
6565
return (aPath: string) => !filter(aPath);
6666
}
6767

68+
async inspect(imageName: ImageName): Promise<ImageInspectInfo> {
69+
try {
70+
log.debug(`Inspecting image: "${imageName.string}"...`);
71+
const imageInfo = await this.dockerode.getImage(imageName.string).inspect();
72+
log.debug(`Inspected image: "${imageName.string}"`);
73+
return imageInfo;
74+
} catch (err) {
75+
log.debug(`Failed to inspect image "${imageName.string}"`);
76+
throw err;
77+
}
78+
}
79+
6880
async exists(imageName: ImageName): Promise<boolean> {
6981
return this.imageExistsLock.acquire(imageName.string, async () => {
7082
if (this.existingImages.has(imageName.string)) {
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { ImageBuildOptions } from "dockerode";
1+
import { ImageBuildOptions, ImageInspectInfo } from "dockerode";
22
import { ImageName } from "../../image-name";
33

44
export interface ImageClient {
55
build(context: string, opts: ImageBuildOptions): Promise<void>;
66
pull(imageName: ImageName, opts?: { force: boolean; platform: string | undefined }): Promise<void>;
7+
inspect(imageName: ImageName): Promise<ImageInspectInfo>;
78
exists(imageName: ImageName): Promise<boolean>;
89
}

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

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

55
export class AbstractStartedContainer implements StartedTestContainer {
66
constructor(protected readonly startedTestContainer: StartedTestContainer) {}
@@ -27,6 +27,10 @@ export class AbstractStartedContainer implements StartedTestContainer {
2727
return this.startedTestContainer.restart(options);
2828
}
2929

30+
public async commit(options: CommitOptions): Promise<string> {
31+
return this.startedTestContainer.commit(options);
32+
}
33+
3034
public getHost(): string {
3135
return this.startedTestContainer.getHost();
3236
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { expect } from "vitest";
2+
import { RandomUuid } from "../common";
3+
import { getContainerRuntimeClient } from "../container-runtime";
4+
import { getReaper } from "../reaper/reaper";
5+
import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
6+
import { deleteImageByName, getImageInfo } from "../utils/test-helper";
7+
import { GenericContainer } from "./generic-container";
8+
9+
describe("GenericContainer commit", { timeout: 180_000 }, () => {
10+
const imageName = "cristianrgreco/testcontainer";
11+
const imageVersion = "1.1.14";
12+
13+
it("should commit container changes to a new image", async () => {
14+
const testContent = "test content";
15+
const newImageTag = `test-commit-${new RandomUuid().nextUuid()}`;
16+
const testAuthor = "test-author";
17+
const testComment = "test-comment";
18+
19+
// Start original container and make a change
20+
const container = await new GenericContainer(`${imageName}:${imageVersion}`).withExposedPorts(8080).start();
21+
22+
// Make a change to the container
23+
await container.exec(["sh", "-c", `echo '${testContent}' > /test-file.txt`]);
24+
25+
// Commit the changes to a new image
26+
const imageId = await container.commit({
27+
repo: imageName,
28+
tag: newImageTag,
29+
author: testAuthor,
30+
comment: testComment,
31+
});
32+
33+
// Verify image metadata is set
34+
const imageInfo = await getImageInfo(imageId);
35+
expect(imageInfo.Author).toBe(testAuthor);
36+
expect(imageInfo.Comment).toBe(testComment);
37+
38+
// Start a new container from the committed image
39+
const newContainer = await new GenericContainer(imageId).withExposedPorts(8080).start();
40+
41+
// Verify the changes exist in the new container
42+
const result = await newContainer.exec(["cat", "/test-file.txt"]);
43+
expect(result.output.trim()).toBe(testContent);
44+
45+
// Cleanup
46+
await container.stop();
47+
await newContainer.stop();
48+
});
49+
50+
it("should add session ID label when deleteOnExit is true", async () => {
51+
const newImageTag = `test-commit-${new RandomUuid().nextUuid()}`;
52+
const container = await new GenericContainer(`${imageName}:${imageVersion}`).withExposedPorts(8080).start();
53+
54+
// Commit with deleteOnExit true (default)
55+
const imageId = await container.commit({
56+
repo: imageName,
57+
tag: newImageTag,
58+
});
59+
60+
// Verify session ID label is present
61+
const imageInfo = await getImageInfo(imageId);
62+
const client = await getContainerRuntimeClient();
63+
const reaper = await getReaper(client);
64+
expect(imageInfo.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBe(reaper.sessionId);
65+
66+
await container.stop();
67+
});
68+
69+
it("should not add session ID label when deleteOnExit is false", async () => {
70+
const newImageTag = `test-commit-${new RandomUuid().nextUuid()}`;
71+
const container = await new GenericContainer(`${imageName}:${imageVersion}`).withExposedPorts(8080).start();
72+
73+
// Commit with deleteOnExit false
74+
const imageId = await container.commit({
75+
repo: imageName,
76+
tag: newImageTag,
77+
changes: ["LABEL test=test", "ENV test=test"],
78+
deleteOnExit: false,
79+
});
80+
81+
const imageInfo = await getImageInfo(imageId);
82+
// Verify session ID label is not present
83+
expect(imageInfo.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBeFalsy();
84+
// Verify other changes are present
85+
expect(imageInfo.Config.Labels.test).toBe("test");
86+
expect(imageInfo.Config.Env).toContain("test=test");
87+
88+
await container.stop();
89+
await deleteImageByName(imageId);
90+
});
91+
});

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import AsyncLock from "async-lock";
33
import Dockerode, { ContainerInspectInfo } from "dockerode";
44
import { Readable } from "stream";
55
import { containerLog, log } from "../common";
6-
import { getContainerRuntimeClient } from "../container-runtime";
6+
import { ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime";
7+
import { getReaper } from "../reaper/reaper";
78
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";
8-
import { ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types";
9+
import { CommitOptions, ContentToCopy, DirectoryToCopy, ExecOptions, ExecResult, FileToCopy, Labels } from "../types";
910
import { BoundPorts } from "../utils/bound-ports";
11+
import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
1012
import { mapInspectResult } from "../utils/map-inspect-result";
1113
import { waitForContainer } from "../wait-strategies/wait-for-container";
1214
import { WaitStrategy } from "../wait-strategies/wait-strategy";
@@ -37,6 +39,38 @@ export class StartedGenericContainer implements StartedTestContainer {
3739
});
3840
}
3941

42+
/**
43+
* Construct the command(s) to apply changes to the container before committing it to an image.
44+
*/
45+
private async getContainerCommitChangeCommands(options: {
46+
deleteOnExit: boolean;
47+
changes?: string[];
48+
client: ContainerRuntimeClient;
49+
}): Promise<string> {
50+
const { deleteOnExit, client } = options;
51+
const changes = options.changes || [];
52+
if (deleteOnExit) {
53+
let sessionId = this.getLabels()[LABEL_TESTCONTAINERS_SESSION_ID];
54+
if (!sessionId) {
55+
sessionId = await getReaper(client).then((reaper) => reaper.sessionId);
56+
}
57+
changes.push(`LABEL ${LABEL_TESTCONTAINERS_SESSION_ID}=${sessionId}`);
58+
} else if (!deleteOnExit && this.getLabels()[LABEL_TESTCONTAINERS_SESSION_ID]) {
59+
// By default, commit will save the existing labels (including the session ID) to the new image. If
60+
// deleteOnExit is false, we need to remove the session ID label.
61+
changes.push(`LABEL ${LABEL_TESTCONTAINERS_SESSION_ID}=`);
62+
}
63+
return changes.join("\n");
64+
}
65+
66+
public async commit(options: CommitOptions): Promise<string> {
67+
const client = await getContainerRuntimeClient();
68+
const { deleteOnExit = true, changes, ...commitOpts } = options;
69+
const changeCommands = await this.getContainerCommitChangeCommands({ deleteOnExit, changes, client });
70+
const imageId = await client.container.commit(this.container, { ...commitOpts, changes: changeCommands });
71+
return imageId;
72+
}
73+
4074
protected containerIsStopped?(): Promise<void>;
4175

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

packages/testcontainers/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export {
1818
TestContainer,
1919
} from "./test-container";
2020
export { TestContainers } from "./test-containers";
21-
export { Content, ExecOptions, ExecResult, InspectResult } from "./types";
21+
export { CommitOptions, Content, ExecOptions, ExecResult, InspectResult } from "./types";
2222
export { BoundPorts } from "./utils/bound-ports";
2323
export { LABEL_TESTCONTAINERS_SESSION_ID } from "./utils/labels";
2424
export { getContainerPort, hasHostBinding, PortWithBinding, PortWithOptionalBinding } from "./utils/port";

0 commit comments

Comments
 (0)