Skip to content

Commit 9046243

Browse files
authored
Add support for reusing stopped containers (#849)
1 parent 1b274c1 commit 9046243

File tree

5 files changed

+59
-11
lines changed

5 files changed

+59
-11
lines changed

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

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

1212
export interface ContainerClient {
1313
dockerode: Dockerode;
1414
getById(id: string): Container;
15-
fetchByLabel(labelName: string, labelValue: string): Promise<Container | undefined>;
15+
fetchByLabel(
16+
labelName: string,
17+
labelValue: string,
18+
opts?: { status?: ContainerStatus[] }
19+
): Promise<Container | undefined>;
1620
fetchArchive(container: Container, path: string): Promise<NodeJS.ReadableStream>;
1721
putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise<void>;
1822
list(): Promise<ContainerInfo[]>;

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import Dockerode, {
99
} from "dockerode";
1010
import { PassThrough, Readable } from "stream";
1111
import { IncomingMessage } from "http";
12-
import { ExecOptions, ExecResult } from "./types";
12+
import { ContainerStatus, ExecOptions, ExecResult } from "./types";
1313
import byline from "byline";
1414
import { ContainerClient } from "./container-client";
1515
import { execLog, log, streamToString } from "../../../common";
@@ -29,15 +29,24 @@ export class DockerContainerClient implements ContainerClient {
2929
}
3030
}
3131

32-
async fetchByLabel(labelName: string, labelValue: string): Promise<Container | undefined> {
32+
async fetchByLabel(
33+
labelName: string,
34+
labelValue: string,
35+
opts: { status?: ContainerStatus[] } | undefined = undefined
36+
): Promise<Container | undefined> {
3337
try {
38+
const filters: { [key: string]: string[] } = {
39+
label: [`${labelName}=${labelValue}`],
40+
};
41+
42+
if (opts?.status) {
43+
filters.status = opts.status;
44+
}
45+
3446
log.debug(`Fetching container by label "${labelName}=${labelValue}"...`);
3547
const containers = await this.dockerode.listContainers({
3648
limit: 1,
37-
filters: {
38-
status: ["running"],
39-
label: [`${labelName}=${labelValue}`],
40-
},
49+
filters,
4150
});
4251
if (containers.length === 0) {
4352
log.debug(`No container found with label "${labelName}=${labelValue}"`);

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ export type Environment = { [key in string]: string };
33
export type ExecOptions = { workingDir: string; user: string; env: Environment; log: boolean };
44

55
export type ExecResult = { output: string; exitCode: number };
6+
7+
export const CONTAINER_STATUSES = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const;
8+
9+
export type ContainerStatus = (typeof CONTAINER_STATUSES)[number];

packages/testcontainers/src/generic-container/generic-container-reuse.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ describe("GenericContainer reuse", () => {
8383
await container1.stop();
8484
});
8585

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

105+
it("should reuse container when an existing reusable container has stopped but not removed", async () => {
106+
const container1 = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
107+
.withName("there_can_only_be_one")
108+
.withExposedPorts(8080)
109+
.withReuse()
110+
.start();
111+
await container1.stop({ remove: false, timeout: 10000 });
112+
113+
const container2 = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
114+
.withName("there_can_only_be_one")
115+
.withExposedPorts(8080)
116+
.withReuse()
117+
.start();
118+
await checkContainerIsHealthy(container2);
119+
120+
expect(container1.getId()).toBe(container2.getId());
121+
await container2.stop();
122+
});
123+
105124
it("should keep the labels passed in when a new reusable container is created", async () => {
106125
const container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14")
107126
.withName("there_can_only_be_one")

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { containerLog, hash, log } from "../common";
3232
import { BoundPorts } from "../utils/bound-ports";
3333
import { StartedNetwork } from "../network/network";
3434
import { mapInspectResult } from "../utils/map-inspect-result";
35+
import { CONTAINER_STATUSES } from "../container-runtime/clients/container/types";
3536

3637
const reusableContainerCreationLock = new AsyncLock();
3738

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

119120
return reusableContainerCreationLock.acquire(containerHash, async () => {
120-
const container = await client.container.fetchByLabel(LABEL_TESTCONTAINERS_CONTAINER_HASH, containerHash);
121+
const container = await client.container.fetchByLabel(LABEL_TESTCONTAINERS_CONTAINER_HASH, containerHash, {
122+
status: CONTAINER_STATUSES.filter(
123+
(status) => status !== "removing" && status !== "dead" && status !== "restarting"
124+
),
125+
});
121126
if (container !== undefined) {
122127
log.debug(`Found container to reuse with hash "${containerHash}"`, { containerId: container.id });
123128
return this.reuseContainer(client, container);
@@ -128,7 +133,14 @@ export class GenericContainer implements TestContainer {
128133
}
129134

130135
private async reuseContainer(client: ContainerRuntimeClient, container: Container) {
131-
const inspectResult = await client.container.inspect(container);
136+
let inspectResult = await client.container.inspect(container);
137+
if (!inspectResult.State.Running) {
138+
log.debug("Reused container is not running, attempting to start it");
139+
await client.container.start(container);
140+
// Refetch the inspect result to get the updated state
141+
inspectResult = await client.container.inspect(container);
142+
}
143+
132144
const mappedInspectResult = mapInspectResult(inspectResult);
133145
const boundPorts = BoundPorts.fromInspectResult(client.info.containerRuntime.hostIps, mappedInspectResult).filter(
134146
this.exposedPorts

0 commit comments

Comments
 (0)