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
13 changes: 13 additions & 0 deletions docs/features/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,19 @@ 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 });
```

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 { CommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types";
import { ContainerCommitOptions, ContainerStatus, ExecOptions, ExecResult } from "./types";

export interface ContainerClient {
dockerode: Dockerode;
Expand All @@ -31,7 +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<string>;
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 { CommitOptions, 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,11 +264,11 @@ export class DockerContainerClient implements ContainerClient {
}
}

async commit(container: Container, opts: CommitOptions): Promise<string> {
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 (Image ID: ${imageId})`, { containerId: container.id });
log.debug(`Committed container to image "${imageId}"`, { containerId: container.id });
return imageId;
} catch (err) {
log.error(`Failed to commit container: ${err}`, { containerId: container.id });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment;

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

export type CommitOptions = {
export type ContainerCommitOptions = {
repo: string;
tag: string;
comment?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ export class DockerImageClient implements ImageClient {

async inspect(imageName: ImageName): Promise<ImageInspectInfo> {
try {
log.debug(`Inspecting image "${imageName.string}"...`);
log.debug(`Inspecting image: "${imageName.string}"...`);
const imageInfo = await this.dockerode.getImage(imageName.string).inspect();
log.debug(`Retrieved image info "${imageName.string}"`);
log.debug(`Inspected image: "${imageName.string}"`);
return imageInfo;
} catch (err) {
log.debug(`Failed to inspect image "${imageName.string}"`);
Expand Down
2 changes: 1 addition & 1 deletion packages/testcontainers/src/container-runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ export { getAuthConfig } from "./auth/get-auth-config";
export { ContainerRuntimeClient, getContainerRuntimeClient } from "./clients/client";
export { parseComposeContainerName } from "./clients/compose/parse-compose-container-name";
export { ComposeDownOptions, ComposeOptions } from "./clients/compose/types";
export { CommitOptions } from "./clients/container/types";
export { ContainerCommitOptions } from "./clients/container/types";
export { HostIp } from "./clients/types";
export { ImageName } from "./image-name";
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import { Readable } from "stream";
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";
import {
ContainerCommitOptions,
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 @@ -35,7 +27,7 @@ export class AbstractStartedContainer implements StartedTestContainer {
return this.startedTestContainer.restart(options);
}

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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
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";
Expand Down Expand Up @@ -57,7 +59,9 @@ describe("GenericContainer commit", { timeout: 180_000 }, () => {

// Verify session ID label is present
const imageInfo = await getImageInfo(imageId);
expect(imageInfo.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBeDefined();
const client = await getContainerRuntimeClient();
const reaper = await getReaper(client);
expect(imageInfo.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBe(reaper.sessionId);

await container.stop();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,7 @@ import { containerLog, log } from "../common";
import { ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime";
import { getReaper } from "../reaper/reaper";
import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container";
import {
ContainerCommitOptions,
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";
Expand Down Expand Up @@ -68,16 +60,14 @@ export class StartedGenericContainer implements StartedTestContainer {
// deleteOnExit is false, we need to remove the session ID label.
changes.push(`LABEL ${LABEL_TESTCONTAINERS_SESSION_ID}=`);
}
return changes.filter(Boolean).join("\n");
return changes.join("\n");
}

public async commit(options: ContainerCommitOptions): Promise<string> {
log.info(`Committing container image...`, { containerId: this.container.id });
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 });
log.info(`Committed container image (Image ID: ${imageId}`, { containerId: this.container.id });
return imageId;
}

Expand Down
7 changes: 6 additions & 1 deletion packages/testcontainers/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
export { IntervalRetry, log, RandomUuid, Retry, Uuid } from "./common";
export { CommitOptions, ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "./container-runtime";
export {
ContainerCommitOptions,
ContainerRuntimeClient,
getContainerRuntimeClient,
ImageName,
} from "./container-runtime";
export { DockerComposeEnvironment } from "./docker-compose-environment/docker-compose-environment";
export { DownedDockerComposeEnvironment } from "./docker-compose-environment/downed-docker-compose-environment";
export { StartedDockerComposeEnvironment } from "./docker-compose-environment/started-docker-compose-environment";
Expand Down
4 changes: 2 additions & 2 deletions packages/testcontainers/src/test-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Readable } from "stream";
import { StartedNetwork } from "./network/network";
import {
BindMount,
ContainerCommitOptions,
CommitOptions,
ContentToCopy,
DirectoryToCopy,
Environment,
Expand Down Expand Up @@ -65,7 +65,7 @@ export interface StopOptions {
export interface StartedTestContainer {
stop(options?: Partial<StopOptions>): Promise<StoppedTestContainer>;
restart(options?: Partial<RestartOptions>): Promise<void>;
commit(options: ContainerCommitOptions): Promise<string>;
commit(options: CommitOptions): Promise<string>;
getHost(): string;
getHostname(): string;
getFirstMappedPort(): number;
Expand Down
4 changes: 2 additions & 2 deletions packages/testcontainers/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Readable } from "stream";
import { CommitOptions } from "./container-runtime";
import { ContainerCommitOptions } from "./container-runtime";

export type InspectResult = {
name: string;
Expand Down Expand Up @@ -91,7 +91,7 @@ export type ExecResult = { output: string; stdout: string; stderr: string; exitC
* @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 ContainerCommitOptions = Omit<CommitOptions, "changes"> & { deleteOnExit?: boolean; changes?: string[] };
export type CommitOptions = Omit<ContainerCommitOptions, "changes"> & { deleteOnExit?: boolean; changes?: string[] };

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

Expand Down