Skip to content

Commit

Permalink
Add support for reusing stopped containers
Browse files Browse the repository at this point in the history
  • Loading branch information
cbrevik committed Nov 4, 2024
1 parent 08da47b commit 0bc18b8
Show file tree
Hide file tree
Showing 5 changed files with 57 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ import Dockerode, {
Network,
} from "dockerode";
import { Readable } from "stream";
import { ExecOptions, ExecResult } from "./types";
import { ContainerStatus, ExecOptions, ExecResult } from "./types";

export interface ContainerClient {
dockerode: Dockerode;
getById(id: string): Container;
fetchByLabel(labelName: string, labelValue: string): Promise<Container | undefined>;
fetchByLabel(
labelName: string,
labelValue: string,
opts?: { status?: ContainerStatus[] }
): Promise<Container | undefined>;
fetchArchive(container: Container, path: string): Promise<NodeJS.ReadableStream>;
putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise<void>;
list(): Promise<ContainerInfo[]>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Dockerode, {
} from "dockerode";
import { PassThrough, Readable } from "stream";
import { IncomingMessage } from "http";
import { ExecOptions, ExecResult } from "./types";
import { ContainerStatus, ExecOptions, ExecResult } from "./types";
import byline from "byline";
import { ContainerClient } from "./container-client";
import { execLog, log, streamToString } from "../../../common";
Expand All @@ -29,15 +29,24 @@ export class DockerContainerClient implements ContainerClient {
}
}

async fetchByLabel(labelName: string, labelValue: string): Promise<Container | undefined> {
async fetchByLabel(
labelName: string,
labelValue: string,
opts: { status?: ContainerStatus[] } | undefined = undefined
): Promise<Container | undefined> {
try {
const filters: { [key: string]: string[] } = {
label: [`${labelName}=${labelValue}`],
};

if (opts?.status) {
filters.status = opts.status;
}

log.debug(`Fetching container by label "${labelName}=${labelValue}"...`);
const containers = await this.dockerode.listContainers({
limit: 1,
filters: {
status: ["running"],
label: [`${labelName}=${labelValue}`],
},
filters,
});
if (containers.length === 0) {
log.debug(`No container found with label "${labelName}=${labelValue}"`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ export type Environment = { [key in string]: string };
export type ExecOptions = { workingDir: string; user: string; env: Environment; log: boolean };

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

export const ContainerStatus = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const;

export type ContainerStatus = (typeof ContainerStatus)[number];
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe("GenericContainer reuse", () => {
await container1.stop();
});

it("should create a new container when an existing reusable container has stopped", async () => {
it("should create a new container when an existing reusable container has stopped and is removed", async () => {
const container1 = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withName("there_can_only_be_one")
.withExposedPorts(8080)
Expand All @@ -102,6 +102,25 @@ describe("GenericContainer reuse", () => {
await container2.stop();
});

it("should reuse container when an existing reusable container has stopped but not removed", async () => {
const container1 = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withName("there_can_only_be_one")
.withExposedPorts(8080)
.withReuse()
.start();
await container1.stop({ remove: false, timeout: 10000 });

const container2 = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withName("there_can_only_be_one")
.withExposedPorts(8080)
.withReuse()
.start();
await checkContainerIsHealthy(container2);

expect(container1.getId()).toBe(container2.getId());
await container2.stop();
});

it("should keep the labels passed in when a new reusable container is created", async () => {
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
.withName("there_can_only_be_one")
Expand Down
14 changes: 12 additions & 2 deletions packages/testcontainers/src/generic-container/generic-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { containerLog, hash, log } from "../common";
import { BoundPorts } from "../utils/bound-ports";
import { StartedNetwork } from "../network/network";
import { mapInspectResult } from "../utils/map-inspect-result";
import { ContainerStatus } from "../container-runtime/clients/container/types";

const reusableContainerCreationLock = new AsyncLock();

Expand Down Expand Up @@ -117,7 +118,9 @@ export class GenericContainer implements TestContainer {
log.debug(`Container reuse has been enabled with hash "${containerHash}"`);

return reusableContainerCreationLock.acquire(containerHash, async () => {
const container = await client.container.fetchByLabel(LABEL_TESTCONTAINERS_CONTAINER_HASH, containerHash);
const container = await client.container.fetchByLabel(LABEL_TESTCONTAINERS_CONTAINER_HASH, containerHash, {
status: ContainerStatus.filter((status) => status !== "removing" && status !== "dead"),
});
if (container !== undefined) {
log.debug(`Found container to reuse with hash "${containerHash}"`, { containerId: container.id });
return this.reuseContainer(client, container);
Expand All @@ -128,7 +131,14 @@ export class GenericContainer implements TestContainer {
}

private async reuseContainer(client: ContainerRuntimeClient, container: Container) {
const inspectResult = await client.container.inspect(container);
let inspectResult = await client.container.inspect(container);
if (!inspectResult.State.Running) {
log.debug("Reused container is not running, attempting to start it");
await client.container.start(container);
// Refetch the inspect result to get the updated state
inspectResult = await client.container.inspect(container);
}

const mappedInspectResult = mapInspectResult(inspectResult);
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter(
this.exposedPorts
Expand Down

0 comments on commit 0bc18b8

Please sign in to comment.