From 0bc18b815dabcb13ff5c87cc23bd62028f884fce Mon Sep 17 00:00:00 2001 From: Christian Brevik Date: Thu, 24 Oct 2024 20:28:32 +0200 Subject: [PATCH 1/3] Add support for reusing stopped containers --- .../clients/container/container-client.ts | 8 +++++-- .../container/docker-container-client.ts | 21 +++++++++++++------ .../clients/container/types.ts | 4 ++++ .../generic-container-reuse.test.ts | 21 ++++++++++++++++++- .../generic-container/generic-container.ts | 14 +++++++++++-- 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/packages/testcontainers/src/container-runtime/clients/container/container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/container-client.ts index 9786c68c4..3c9d13784 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/container-client.ts @@ -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; + fetchByLabel( + labelName: string, + labelValue: string, + opts?: { status?: ContainerStatus[] } + ): Promise; fetchArchive(container: Container, path: string): Promise; putArchive(container: Dockerode.Container, stream: Readable, path: string): Promise; list(): Promise; diff --git a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts index 9b413c36d..226e58b73 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts @@ -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"; @@ -29,15 +29,24 @@ export class DockerContainerClient implements ContainerClient { } } - async fetchByLabel(labelName: string, labelValue: string): Promise { + async fetchByLabel( + labelName: string, + labelValue: string, + opts: { status?: ContainerStatus[] } | undefined = undefined + ): Promise { 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}"`); diff --git a/packages/testcontainers/src/container-runtime/clients/container/types.ts b/packages/testcontainers/src/container-runtime/clients/container/types.ts index 127289cdb..a707eb684 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/types.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/types.ts @@ -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]; diff --git a/packages/testcontainers/src/generic-container/generic-container-reuse.test.ts b/packages/testcontainers/src/generic-container/generic-container-reuse.test.ts index efc456851..4f50c0fd1 100644 --- a/packages/testcontainers/src/generic-container/generic-container-reuse.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-reuse.test.ts @@ -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) @@ -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") diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 373f31ce5..93bdbb559 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -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(); @@ -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); @@ -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 From 288d63af41065d10512b6ebc4d35b2c85d64c52a Mon Sep 17 00:00:00 2001 From: Christian Brevik Date: Mon, 4 Nov 2024 21:31:02 +0100 Subject: [PATCH 2/3] chore: Also exclude restarting --- .../testcontainers/src/generic-container/generic-container.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 93bdbb559..6069286f9 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -119,7 +119,9 @@ export class GenericContainer implements TestContainer { return reusableContainerCreationLock.acquire(containerHash, async () => { const container = await client.container.fetchByLabel(LABEL_TESTCONTAINERS_CONTAINER_HASH, containerHash, { - status: ContainerStatus.filter((status) => status !== "removing" && status !== "dead"), + status: ContainerStatus.filter( + (status) => status !== "removing" && status !== "dead" && status !== "restarting" + ), }); if (container !== undefined) { log.debug(`Found container to reuse with hash "${containerHash}"`, { containerId: container.id }); From 67664f3d68848d8c52ed5c66d90d239ca4183816 Mon Sep 17 00:00:00 2001 From: Christian Brevik Date: Tue, 5 Nov 2024 15:38:26 +0100 Subject: [PATCH 3/3] Rename to CONTAINER_STATUSES --- .../src/container-runtime/clients/container/types.ts | 4 ++-- .../testcontainers/src/generic-container/generic-container.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/testcontainers/src/container-runtime/clients/container/types.ts b/packages/testcontainers/src/container-runtime/clients/container/types.ts index a707eb684..7062d54ce 100644 --- a/packages/testcontainers/src/container-runtime/clients/container/types.ts +++ b/packages/testcontainers/src/container-runtime/clients/container/types.ts @@ -4,6 +4,6 @@ export type ExecOptions = { workingDir: string; user: string; env: Environment; export type ExecResult = { output: string; exitCode: number }; -export const ContainerStatus = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const; +export const CONTAINER_STATUSES = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] as const; -export type ContainerStatus = (typeof ContainerStatus)[number]; +export type ContainerStatus = (typeof CONTAINER_STATUSES)[number]; diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 6069286f9..4ebe2899f 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -32,7 +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"; +import { CONTAINER_STATUSES } from "../container-runtime/clients/container/types"; const reusableContainerCreationLock = new AsyncLock(); @@ -119,7 +119,7 @@ export class GenericContainer implements TestContainer { return reusableContainerCreationLock.acquire(containerHash, async () => { const container = await client.container.fetchByLabel(LABEL_TESTCONTAINERS_CONTAINER_HASH, containerHash, { - status: ContainerStatus.filter( + status: CONTAINER_STATUSES.filter( (status) => status !== "removing" && status !== "dead" && status !== "restarting" ), });