diff --git a/docs/features/compose.md b/docs/features/compose.md index 3054f03a2..f4207da6e 100644 --- a/docs/features/compose.md +++ b/docs/features/compose.md @@ -144,6 +144,19 @@ const environment = await new DockerComposeEnvironment(composeFilePath, composeF .up(); ``` +### With auto cleanup disabled + +By default Testcontainers registers the compose project with Ryuk so the stack is torn down automatically when the process exits. You can disable that registration for a specific compose stack: + +```js +const environment = await new DockerComposeEnvironment(composeFilePath, composeFile) + .withProjectName("test") + .withAutoCleanup(false) + .up(); +``` + +This only disables automatic cleanup. Explicit calls to `.down()`, `.stop()`, or `await using` disposal still tear the stack down as usual. + ### With custom client options See [docker-compose](https://github.com/PDMLab/docker-compose/) library. diff --git a/docs/features/containers.md b/docs/features/containers.md index 70672b051..743444034 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -400,6 +400,16 @@ const container = await new GenericContainer("alpine") await container.stop({ remove: true }); // The container is stopped *AND* removed ``` +You can also disable automatic Ryuk cleanup for a specific container while leaving it enabled for the rest of the test session: + +```js +const container = await new GenericContainer("alpine") + .withAutoCleanup(false) + .start(); +``` + +This only affects automatic cleanup when the process exits unexpectedly or the container is otherwise left running. Explicit calls to `.stop()` still use the normal stop and removal behavior, so combine this with `.withAutoRemove(false)` or `.stop({ remove: false })` if you also want explicit stops to keep the container. + Keep in mind that disabling ryuk (set `TESTCONTAINERS_RYUK_DISABLED` to `true`) **and** disabling automatic removal of containers will make containers persist after you're done working with them. diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment-auto-cleanup.test.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment-auto-cleanup.test.ts new file mode 100644 index 000000000..2e8c9d190 --- /dev/null +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment-auto-cleanup.test.ts @@ -0,0 +1,77 @@ +import { ContainerInfo } from "dockerode"; +import { ContainerRuntimeClient } from "../container-runtime"; +import { DockerComposeEnvironment } from "./docker-compose-environment"; + +let mockClient: ContainerRuntimeClient; +let mockGetReaper = vi.fn(); + +vi.mock("../container-runtime", async () => ({ + ...(await vi.importActual("../container-runtime")), + getContainerRuntimeClient: vi.fn(async () => mockClient), +})); + +vi.mock("../reaper/reaper", async () => ({ + ...(await vi.importActual("../reaper/reaper")), + getReaper: vi.fn(async (client: ContainerRuntimeClient) => await mockGetReaper(client)), +})); + +describe.sequential("DockerComposeEnvironment auto cleanup", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetReaper = vi.fn(); + }); + + it("should register the compose project with the reaper by default", async () => { + mockClient = createMockContainerRuntimeClient(); + const addComposeProject = vi.fn(); + mockGetReaper.mockResolvedValue({ + sessionId: "session-id", + containerId: "reaper-container-id", + addComposeProject, + addSession: vi.fn(), + }); + + await new DockerComposeEnvironment("/tmp", "docker-compose.yml").withProjectName("my-project").up(); + + expect(mockGetReaper).toHaveBeenCalledWith(mockClient); + expect(addComposeProject).toHaveBeenCalledWith("my-project"); + }); + + it("should not register the compose project with the reaper when auto cleanup is disabled", async () => { + mockClient = createMockContainerRuntimeClient(); + + await new DockerComposeEnvironment("/tmp", "docker-compose.yml") + .withProjectName("my-project") + .withAutoCleanup(false) + .up(); + + expect(mockGetReaper).not.toHaveBeenCalled(); + }); +}); + +function createMockContainerRuntimeClient(): ContainerRuntimeClient { + return { + compose: { + down: vi.fn(), + pull: vi.fn(), + stop: vi.fn(), + up: vi.fn(), + }, + container: { + list: vi.fn(async () => [] as ContainerInfo[]), + }, + image: {}, + info: { + containerRuntime: { + host: "localhost", + hostIps: [{ address: "127.0.0.1", family: 4 }], + }, + node: { + architecture: "x64", + platform: "linux", + version: process.version, + }, + }, + network: {}, + } as unknown as ContainerRuntimeClient; +} diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts index 3f7357148..b646a57e3 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts @@ -18,6 +18,7 @@ export class DockerComposeEnvironment { private projectName: string; private build = false; + private autoCleanup = true; private recreate = true; private environmentFile = ""; private profiles: string[] = []; @@ -39,6 +40,11 @@ export class DockerComposeEnvironment { return this; } + public withAutoCleanup(autoCleanup: boolean): this { + this.autoCleanup = autoCleanup; + return this; + } + public withEnvironment(environment: Environment): this { this.environment = { ...this.environment, ...environment }; return this; @@ -95,8 +101,10 @@ export class DockerComposeEnvironment { public async up(services?: Array): Promise { log.info(`Starting DockerCompose environment "${this.projectName}"...`); const client = await getContainerRuntimeClient(); - const reaper = await getReaper(client); - reaper.addComposeProject(this.projectName); + if (this.autoCleanup) { + const reaper = await getReaper(client); + reaper.addComposeProject(this.projectName); + } const { composeOptions: clientComposeOptions = [], diff --git a/packages/testcontainers/src/generic-container/generic-container-auto-cleanup.test.ts b/packages/testcontainers/src/generic-container/generic-container-auto-cleanup.test.ts new file mode 100644 index 000000000..f65d630f9 --- /dev/null +++ b/packages/testcontainers/src/generic-container/generic-container-auto-cleanup.test.ts @@ -0,0 +1,140 @@ +import { Container, ContainerCreateOptions, ContainerInspectInfo } from "dockerode"; +import { Readable } from "stream"; +import { ContainerRuntimeClient } from "../container-runtime"; +import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; +import { WaitStrategy } from "../wait-strategies/wait-strategy"; +import { GenericContainer } from "./generic-container"; + +let mockClient: ContainerRuntimeClient; +let mockGetReaper = vi.fn(); + +vi.mock("../container-runtime", async () => ({ + ...(await vi.importActual("../container-runtime")), + getContainerRuntimeClient: vi.fn(async () => mockClient), +})); + +vi.mock("../reaper/reaper", async () => ({ + ...(await vi.importActual("../reaper/reaper")), + getReaper: vi.fn(async (client: ContainerRuntimeClient) => await mockGetReaper(client)), +})); + +describe.sequential("GenericContainer auto cleanup", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetReaper = vi.fn(); + }); + + it("should register the container with the reaper by default", async () => { + const { client, inspectResult } = createMockContainerRuntimeClient(); + mockClient = client; + mockGetReaper.mockResolvedValue({ + sessionId: "session-id", + containerId: "reaper-container-id", + addComposeProject: vi.fn(), + addSession: vi.fn(), + }); + + const container = await new GenericContainer("alpine").withWaitStrategy(createNoopWaitStrategy()).start(); + + expect(mockGetReaper).toHaveBeenCalledWith(client); + expect(container.getLabels()[LABEL_TESTCONTAINERS_SESSION_ID]).toBe("session-id"); + expect(inspectResult.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBe("session-id"); + }); + + it("should not register the container with the reaper when auto cleanup is disabled", async () => { + const { client, inspectResult } = createMockContainerRuntimeClient(); + mockClient = client; + + const container = await new GenericContainer("alpine") + .withAutoCleanup(false) + .withWaitStrategy(createNoopWaitStrategy()) + .start(); + + expect(mockGetReaper).not.toHaveBeenCalled(); + expect(container.getLabels()[LABEL_TESTCONTAINERS_SESSION_ID]).toBeUndefined(); + expect(inspectResult.Config.Labels[LABEL_TESTCONTAINERS_SESSION_ID]).toBeUndefined(); + }); +}); + +function createMockContainerRuntimeClient(): { + client: ContainerRuntimeClient; + inspectResult: ContainerInspectInfo; +} { + const inspectResult = { + Config: { + Hostname: "mock-hostname", + Labels: {}, + }, + HostConfig: { + PortBindings: {}, + }, + Name: "/mock-container", + NetworkSettings: { + Networks: {}, + Ports: {}, + }, + State: { + FinishedAt: "0001-01-01T00:00:00Z", + Running: true, + StartedAt: new Date().toISOString(), + Status: "running", + }, + } as ContainerInspectInfo; + + const container = { + id: "mock-container-id", + } as Container; + + const client = { + compose: {}, + container: { + attach: vi.fn(), + commit: vi.fn(), + connectToNetwork: vi.fn(), + create: vi.fn(async (opts: ContainerCreateOptions) => { + inspectResult.Config.Labels = opts.Labels ?? {}; + return container; + }), + dockerode: {} as never, + events: vi.fn(), + exec: vi.fn(), + fetchArchive: vi.fn(), + fetchByLabel: vi.fn(), + getById: vi.fn(), + inspect: vi.fn(async () => inspectResult), + list: vi.fn(), + logs: vi.fn(async () => Readable.from([])), + putArchive: vi.fn(), + remove: vi.fn(), + restart: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }, + image: { + pull: vi.fn(), + }, + info: { + containerRuntime: { + host: "localhost", + hostIps: [{ address: "127.0.0.1", family: 4 }], + }, + node: { + architecture: "x64", + platform: "linux", + version: process.version, + }, + }, + network: { + getById: vi.fn(), + }, + } as unknown as ContainerRuntimeClient; + + return { client, inspectResult }; +} + +function createNoopWaitStrategy(): WaitStrategy { + return { + waitUntilReady: vi.fn(async () => undefined), + withStartupTimeout: vi.fn().mockReturnThis(), + } as unknown as WaitStrategy; +} diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 937d29732..313818c58 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -54,6 +54,7 @@ export class GenericContainer implements TestContainer { protected environment: Record = {}; protected exposedPorts: PortWithOptionalBinding[] = []; protected reuse = false; + protected autoCleanup = true; protected autoRemove = true; protected networkMode?: string; protected networkAliases: string[] = []; @@ -112,7 +113,7 @@ export class GenericContainer implements TestContainer { return this.reuseOrStartContainer(client); } - if (!this.isReaper()) { + if (!this.isReaper() && this.autoCleanup) { const reaper = await getReaper(client); this.createOpts.Labels = { ...this.createOpts.Labels, [LABEL_TESTCONTAINERS_SESSION_ID]: reaper.sessionId }; } @@ -461,6 +462,11 @@ export class GenericContainer implements TestContainer { return this; } + public withAutoCleanup(autoCleanup: boolean): this { + this.autoCleanup = autoCleanup; + return this; + } + public withAutoRemove(autoRemove: boolean): this { this.autoRemove = autoRemove; return this; diff --git a/packages/testcontainers/src/test-container.ts b/packages/testcontainers/src/test-container.ts index 73bf5b1e7..c92da01cf 100644 --- a/packages/testcontainers/src/test-container.ts +++ b/packages/testcontainers/src/test-container.ts @@ -44,6 +44,7 @@ export interface TestContainer { withUser(user: string): this; withPullPolicy(pullPolicy: ImagePullPolicy): this; withReuse(): this; + withAutoCleanup(autoCleanup: boolean): this; withAutoRemove(autoRemove: boolean): this; withCopyFilesToContainer(filesToCopy: FileToCopy[]): this; withCopyDirectoriesToContainer(directoriesToCopy: DirectoryToCopy[]): this;