Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
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 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
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
16 changes: 14 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 { CONTAINER_STATUSES } from "../container-runtime/clients/container/types";

const reusableContainerCreationLock = new AsyncLock();

Expand Down Expand Up @@ -117,7 +118,11 @@ 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: CONTAINER_STATUSES.filter(
(status) => status !== "removing" && status !== "dead" && status !== "restarting"
),
});
if (container !== undefined) {
log.debug(`Found container to reuse with hash "${containerHash}"`, { containerId: container.id });
return this.reuseContainer(client, container);
Expand All @@ -128,7 +133,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
Loading