From dc7ece9b4afb3845a25cda4e7575f8895a9c6083 Mon Sep 17 00:00:00 2001 From: gologames Date: Mon, 14 Sep 2026 15:54:35 +0200 Subject: [PATCH] Add never-pull policy --- docs/features/compose.md | 14 +++ docs/features/containers.md | 15 +++ docs/features/images.md | 4 + .../docker-compose-with-never-pull-build.yml | 7 ++ .../docker-compose-with-never-pull.yml | 10 ++ .../docker-compose-environment.test.ts | 117 ++++++++++++++++++ .../docker-compose-environment.ts | 12 +- .../generic-container-builder.ts | 12 +- .../generic-container-dockerfile.test.ts | 31 +++++ .../generic-container.test.ts | 54 ++++++++ .../generic-container/generic-container.ts | 15 ++- .../src/utils/pull-policy.test.ts | 49 +++++++- .../testcontainers/src/utils/pull-policy.ts | 28 +++++ .../src/utils/test-helper.test.ts | 42 +++++++ .../testcontainers/src/utils/test-helper.ts | 41 +++++- 15 files changed, 434 insertions(+), 17 deletions(-) create mode 100644 packages/testcontainers/fixtures/docker-compose/docker-compose-with-never-pull-build.yml create mode 100644 packages/testcontainers/fixtures/docker-compose/docker-compose-with-never-pull.yml diff --git a/docs/features/compose.md b/docs/features/compose.md index 86dbfad49..5acbd5066 100644 --- a/docs/features/compose.md +++ b/docs/features/compose.md @@ -67,6 +67,18 @@ const environment = await new DockerComposeEnvironment(composeFilePath, composeF .up(); ``` +To start services using only locally available images, use `PullPolicy.neverPull()`: + +```js +const { DockerComposeEnvironment, PullPolicy } = require("testcontainers"); + +const environment = await new DockerComposeEnvironment(composeFilePath, composeFile) + .withPullPolicy(PullPolicy.neverPull()) + .up(); +``` + +This passes `--pull never --no-build` to Compose, overriding pull and build settings for service images. Startup fails if a required image is missing locally. Combining this policy with `withBuild()` or an enabled `--build` option throws an error. Helper images such as Ryuk are unaffected. + Create a custom pull policy: ```ts @@ -83,6 +95,8 @@ const environment = await new DockerComposeEnvironment(composeFilePath, composeF .up(); ``` +Custom policies follow the same [pull policy rules as containers](containers.md#with-a-pull-policy). + ### With rebuild ```js diff --git a/docs/features/containers.md b/docs/features/containers.md index 743444034..3019d4aa7 100644 --- a/docs/features/containers.md +++ b/docs/features/containers.md @@ -28,6 +28,19 @@ const container = await new GenericContainer("alpine") .start(); ``` +To use only an image that is already available locally, use `PullPolicy.neverPull()`: + +```js +const { GenericContainer, PullPolicy } = require("testcontainers"); + +const container = await new GenericContainer("my-app:local") + .withPullPolicy(PullPolicy.neverPull()) + .start(); +``` + +Startup fails if the image is missing locally, without attempting to pull it from a registry. +This policy only prevents pulling the specified image. Testcontainers may still pull Ryuk. + Create a custom pull policy: ```ts @@ -44,6 +57,8 @@ const container = await new GenericContainer("alpine") .start(); ``` +Custom policies can forbid pulling by returning `true` from `neverPull()` and `false` from `shouldPull()`; returning `true` from both throws an error. + ### With a command ```js diff --git a/docs/features/images.md b/docs/features/images.md index 96af1d58e..7cfc18459 100644 --- a/docs/features/images.md +++ b/docs/features/images.md @@ -65,6 +65,10 @@ const container = await GenericContainer .build(); ``` +`PullPolicy.neverPull()` and custom never-pull policies throw an error for Dockerfile builds, with or without BuildKit: the Docker build API cannot forbid base image pulls ([docker/buildx#1889](https://github.com/docker/buildx/issues/1889)). + +For an already built image, use [a container with a never-pull policy](containers.md#with-a-pull-policy). + ### With build arguments ```js diff --git a/packages/testcontainers/fixtures/docker-compose/docker-compose-with-never-pull-build.yml b/packages/testcontainers/fixtures/docker-compose/docker-compose-with-never-pull-build.yml new file mode 100644 index 000000000..d5d590d21 --- /dev/null +++ b/packages/testcontainers/fixtures/docker-compose/docker-compose-with-never-pull-build.yml @@ -0,0 +1,7 @@ +services: + container: + image: ${TEST_IMAGE} + build: + context: ../docker/docker + ports: + - 8080 diff --git a/packages/testcontainers/fixtures/docker-compose/docker-compose-with-never-pull.yml b/packages/testcontainers/fixtures/docker-compose/docker-compose-with-never-pull.yml new file mode 100644 index 000000000..0e9b1e61f --- /dev/null +++ b/packages/testcontainers/fixtures/docker-compose/docker-compose-with-never-pull.yml @@ -0,0 +1,10 @@ +services: + container: + image: ${TEST_IMAGE} + pull_policy: always + ports: + - 8080 + other: + image: ${OTHER_IMAGE} + ports: + - 8080 diff --git a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts index 02b040817..cfb67157b 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.test.ts @@ -1,9 +1,11 @@ import path from "path"; import { log, RandomUuid } from "../common"; import { randomUuid } from "../common/uuid"; +import { getContainerRuntimeClient, ImageName } from "../container-runtime"; import { PullPolicy } from "../utils/pull-policy"; import { checkEnvironmentContainerIsHealthy, + createTempImageTag, getDockerEventStream, getHealthCheckStatus, getRunningContainerNames, @@ -73,6 +75,121 @@ describe("DockerComposeEnvironment", { timeout: 180_000 }, () => { } }); + it.each([ + { commandOptions: ["--no-color", "--pull=never", "--no-build=true"] }, + { commandOptions: ["--build=true", "--build=false", "--no-build=1"] }, + { commandOptions: ["--build=0", "--pull", "always", "--no-build=false"] }, + { commandOptions: ["--pull=missing"] }, + ])( + "should start local service images with a never-pull policy and options $commandOptions", + { concurrent: false }, + async ({ commandOptions }) => { + const client = await getContainerRuntimeClient(); + await using image = await createTempImageTag("cristianrgreco/testcontainer:1.1.14"); + const upSpy = vi.spyOn(client.compose, "up"); + const originalCommandOptions = [...commandOptions]; + + await using dockerEventStream = await getDockerEventStream(); + const dockerPullEventPromise = waitForDockerEvent(dockerEventStream.events, "pull", 1, image.name); + const dockerStartEventPromise = waitForDockerEvent(dockerEventStream.events, "start", 2, image.name); + let hasPulled = false; + dockerPullEventPromise.then(() => (hasPulled = true)); + + await using environment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-never-pull.yml") + .withEnvironment({ TEST_IMAGE: image.name, OTHER_IMAGE: image.name }) + .withPullPolicy(PullPolicy.neverPull()) + .withClientOptions({ commandOptions }) + .up(); + + await checkEnvironmentContainerIsHealthy(environment, "container-1"); + await checkEnvironmentContainerIsHealthy(environment, "other-1"); + await dockerStartEventPromise; + expect(hasPulled).toBe(false); + expect(upSpy).toHaveBeenCalledWith( + expect.objectContaining({ commandOptions: [...originalCommandOptions, "--pull", "never", "--no-build"] }), + undefined + ); + expect(commandOptions).toEqual(originalCommandOptions); + } + ); + + it("should apply a never-pull policy to selected services", async () => { + const client = await getContainerRuntimeClient(); + const image = ImageName.fromString("cristianrgreco/testcontainer:1.1.14"); + await client.image.pull(image); + + await using environment = await new DockerComposeEnvironment(fixtures, "docker-compose-with-never-pull.yml") + .withEnvironment({ + TEST_IMAGE: image.string, + OTHER_IMAGE: `localhost/testcontainers-missing-${randomUuid()}:latest`, + }) + .withPullPolicy(PullPolicy.neverPull()) + .up(["container"]); + + await checkEnvironmentContainerIsHealthy(environment, "container-1"); + expect(() => environment.getContainer("other-1")).toThrow('Cannot get container "other-1" as it is not running'); + }); + + it("should fail without pulling when a service image is missing", { concurrent: false }, async () => { + const client = await getContainerRuntimeClient(); + const pullSpy = vi.spyOn(client.compose, "pull"); + const upSpy = vi.spyOn(client.compose, "up"); + const image = `localhost/testcontainers-missing-${randomUuid()}:latest`; + + await expect( + new DockerComposeEnvironment(fixtures, "docker-compose-with-never-pull.yml") + .withEnvironment({ TEST_IMAGE: image, OTHER_IMAGE: image }) + .withPullPolicy(PullPolicy.neverPull()) + .up(["container"]) + ).rejects.toThrow(/No such image|image not known/i); + expect(pullSpy).not.toHaveBeenCalled(); + expect(upSpy).toHaveBeenCalledWith(expect.objectContaining({ commandOptions: ["--pull", "never", "--no-build"] }), [ + "container", + ]); + }); + + it("should not implicitly build a missing service image with a never-pull policy", async () => { + const client = await getContainerRuntimeClient(); + const image = ImageName.fromString(`localhost/testcontainers-missing-${randomUuid()}:latest`); + + await expect( + new DockerComposeEnvironment(fixtures, "docker-compose-with-never-pull-build.yml") + .withEnvironment({ TEST_IMAGE: image.string }) + .withPullPolicy(PullPolicy.neverPull()) + .up() + ).rejects.toThrow(/No such image|image not known/i); + await expect(client.image.inspect(image)).rejects.toMatchObject({ statusCode: 404 }); + }); + + it("should reject explicit builds with a never-pull policy", async () => { + await expect( + new DockerComposeEnvironment(fixtures, "docker-compose.yml") + .withPullPolicy(PullPolicy.neverPull()) + .withBuild() + .up() + ).rejects.toThrow("Never-pull policies cannot be combined with Compose builds"); + }); + + it.each([{ commandOptions: ["--build"] }, { commandOptions: ["--build=true"] }])( + "should reject enabled Compose build options $commandOptions", + async ({ commandOptions }) => { + await expect( + new DockerComposeEnvironment(fixtures, "docker-compose.yml") + .withPullPolicy(PullPolicy.neverPull()) + .withClientOptions({ commandOptions }) + .up() + ).rejects.toThrow("--build and --no-build are incompatible"); + } + ); + + it("should reject conflicting pull settings", async () => { + await expect( + new DockerComposeEnvironment(fixtures, "docker-compose.yml") + .withPullPolicy({ shouldPull: () => true, neverPull: () => true }) + .up() + ).rejects.toThrow("Image pull policy cannot enable both shouldPull() and neverPull()"); + }); + it("should start environment with multiple compose files", async () => { const overrideFixtures = path.resolve(fixtures, "docker-compose-with-override"); 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 f13578f0e..3fe9e9137 100644 --- a/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts +++ b/packages/testcontainers/src/docker-compose-environment/docker-compose-environment.ts @@ -6,7 +6,7 @@ import { getReaper } from "../reaper/reaper"; import { Environment } from "../types"; import { BoundPorts } from "../utils/bound-ports"; import { mapInspectResult } from "../utils/map-inspect-result"; -import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy"; +import { ImagePullPolicy, PullPolicy, resolvePullPolicy } from "../utils/pull-policy"; import { selectWaitStrategy } from "../wait-strategies/utils/wait-strategy-selector"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; @@ -120,9 +120,17 @@ export class DockerComposeEnvironment { }; const commandOptions = [...clientCommandOptions]; + + const pullPolicy = resolvePullPolicy(this.pullPolicy); if (this.build) { + if (pullPolicy === "never") { + throw new Error("Never-pull policies cannot be combined with Compose builds"); + } commandOptions.push("--build"); } + if (pullPolicy === "never") { + commandOptions.push("--pull", "never", "--no-build"); + } if (!this.recreate) { commandOptions.push("--no-recreate"); } @@ -133,7 +141,7 @@ export class DockerComposeEnvironment { } this.profiles.forEach((profile) => composeOptions.push("--profile", profile)); - if (this.pullPolicy.shouldPull()) { + if (pullPolicy === "always") { await client.compose.pull(options, services); } await client.compose.up( diff --git a/packages/testcontainers/src/generic-container/generic-container-builder.ts b/packages/testcontainers/src/generic-container/generic-container-builder.ts index 675aa907b..c63ce74f1 100644 --- a/packages/testcontainers/src/generic-container/generic-container-builder.ts +++ b/packages/testcontainers/src/generic-container/generic-container-builder.ts @@ -7,7 +7,7 @@ import { getReaper } from "../reaper/reaper"; import { BuildArgs, RegistryConfig } from "../types"; import { getDockerfileImages } from "../utils/dockerfile-parser"; import { createLabels, LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; -import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy"; +import { ImagePullPolicy, PullPolicy, resolvePullPolicy } from "../utils/pull-policy"; import { GenericContainer } from "./generic-container"; export type BuildOptions = { @@ -62,6 +62,14 @@ export class GenericContainerBuilder { image = `localhost/${this.uuid.nextUuid()}:${this.uuid.nextUuid()}`, options: BuildOptions = { deleteOnExit: true } ): Promise { + const pullPolicy = resolvePullPolicy(this.pullPolicy); + // https://github.com/docker/buildx/issues/1889 + if (pullPolicy === "never") { + throw new Error( + "Never-pull policies are not supported for Dockerfile builds: the Docker build API cannot forbid image pulls" + ); + } + const client = await getContainerRuntimeClient(); const reaper = await getReaper(client); @@ -90,7 +98,7 @@ export class GenericContainerBuilder { version: this.buildkit ? "2" : "1", }; - if (this.pullPolicy.shouldPull()) { + if (pullPolicy === "always") { buildOptions.pull = true; } diff --git a/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts b/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts index 243b5cfc6..41cfcff82 100644 --- a/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts @@ -1,5 +1,6 @@ import path from "path"; import { RandomUuid } from "../common"; +import * as containerRuntime from "../container-runtime"; import { getContainerRuntimeClient, ImageName } from "../container-runtime"; import { getReaper } from "../reaper/reaper"; import { LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; @@ -93,6 +94,36 @@ describe("GenericContainer Dockerfile", { timeout: 180_000 }, () => { }); } + for (const buildkit of [false, true]) { + it( + `should reject never-pull before contacting the runtime with buildkit=${buildkit}`, + { concurrent: false }, + async () => { + const clientSpy = vi.spyOn(containerRuntime, "getContainerRuntimeClient"); + const builder = GenericContainer.fromDockerfile(path.resolve(fixtures, "docker")).withPullPolicy( + PullPolicy.neverPull() + ); + if (buildkit) { + builder.withBuildkit(); + } + + await expect(builder.build()).rejects.toThrow("Never-pull policies are not supported for Dockerfile builds"); + expect(clientSpy).not.toHaveBeenCalled(); + } + ); + } + + it("should reject conflicting pull settings before contacting the runtime", { concurrent: false }, async () => { + const clientSpy = vi.spyOn(containerRuntime, "getContainerRuntimeClient"); + + await expect( + GenericContainer.fromDockerfile(path.resolve(fixtures, "docker")) + .withPullPolicy({ shouldPull: () => true, neverPull: () => true }) + .build() + ).rejects.toThrow("Image pull policy cannot enable both shouldPull() and neverPull()"); + expect(clientSpy).not.toHaveBeenCalled(); + }); + it("should build and start with custom file name", async () => { const context = path.resolve(fixtures, "docker-with-custom-filename"); const container = await GenericContainer.fromDockerfile(context, "Dockerfile-A").build(); diff --git a/packages/testcontainers/src/generic-container/generic-container.test.ts b/packages/testcontainers/src/generic-container/generic-container.test.ts index 2e7f999a0..5b23be031 100644 --- a/packages/testcontainers/src/generic-container/generic-container.test.ts +++ b/packages/testcontainers/src/generic-container/generic-container.test.ts @@ -2,11 +2,13 @@ import archiver from "archiver"; import getPort from "get-port"; import path from "path"; import { RandomUuid } from "../common"; +import * as containerRuntime from "../container-runtime"; import { getContainerRuntimeClient } from "../container-runtime"; import { PullPolicy } from "../utils/pull-policy"; import { checkContainerIsHealthy, checkContainerIsHealthyUdp, + createTempImageTag, createTempSymlinkedFile, getDockerEventStream, getRunningContainerNames, @@ -344,6 +346,58 @@ describe("GenericContainer", { timeout: 180_000 }, () => { } }); + it("should start a local image without pulling with a never-pull policy", async () => { + await using image = await createTempImageTag("cristianrgreco/testcontainer:1.1.14"); + await using dockerEventStream = await getDockerEventStream(); + const dockerPullEventPromise = waitForDockerEvent(dockerEventStream.events, "pull", 1, image.name); + const dockerStartEventPromise = waitForDockerEvent(dockerEventStream.events, "start", 1, image.name); + let hasPulled = false; + dockerPullEventPromise.then(() => (hasPulled = true)); + + await using container = await new GenericContainer(image.name) + .withPullPolicy(PullPolicy.neverPull()) + .withExposedPorts(8080) + .start(); + + await checkContainerIsHealthy(container); + await dockerStartEventPromise; + expect(hasPulled).toBe(false); + }); + + it("should fail without pulling when a local image is missing", { concurrent: false }, async () => { + const client = await getContainerRuntimeClient(); + const pullSpy = vi.spyOn(client.image, "pull"); + const image = `localhost/testcontainers-missing-${new RandomUuid().nextUuid()}:latest`; + + await expect(new GenericContainer(image).withPullPolicy(PullPolicy.neverPull()).start()).rejects.toMatchObject({ + statusCode: 404, + }); + expect(pullSpy).not.toHaveBeenCalled(); + }); + + it("should propagate image inspection failures without pulling", { concurrent: false }, async () => { + const client = await getContainerRuntimeClient(); + const error = new Error("Image inspection failed"); + vi.spyOn(client.image, "inspect").mockRejectedValueOnce(error); + const pullSpy = vi.spyOn(client.image, "pull"); + + await expect( + new GenericContainer("cristianrgreco/testcontainer:1.1.14").withPullPolicy(PullPolicy.neverPull()).start() + ).rejects.toBe(error); + expect(pullSpy).not.toHaveBeenCalled(); + }); + + it("should reject conflicting pull settings before contacting the runtime", { concurrent: false }, async () => { + const clientSpy = vi.spyOn(containerRuntime, "getContainerRuntimeClient"); + + await expect( + new GenericContainer("cristianrgreco/testcontainer:1.1.14") + .withPullPolicy({ shouldPull: () => true, neverPull: () => true }) + .start() + ).rejects.toThrow("Image pull policy cannot enable both shouldPull() and neverPull()"); + expect(clientSpy).not.toHaveBeenCalled(); + }); + it("should set the IPC mode", async () => { await using container = await new GenericContainer("cristianrgreco/testcontainer:1.1.14") .withIpcMode("host") diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index 7d8aba8f5..4eb5a3783 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -30,7 +30,7 @@ import { BoundPorts } from "../utils/bound-ports"; import { createLabels, LABEL_TESTCONTAINERS_CONTAINER_HASH, LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels"; import { mapInspectResult } from "../utils/map-inspect-result"; import { getContainerPort, getProtocol, hasHostBinding, PortWithOptionalBinding } from "../utils/port"; -import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy"; +import { ImagePullPolicy, PullPolicy, resolvePullPolicy } from "../utils/pull-policy"; import { selectWaitStrategy } from "../wait-strategies/utils/wait-strategy-selector"; import { waitForContainer } from "../wait-strategies/wait-for-container"; import { WaitStrategy } from "../wait-strategies/wait-strategy"; @@ -88,11 +88,16 @@ export class GenericContainer implements TestContainer { protected containerStarting?(inspectResult: InspectResult, reused: boolean): Promise; public async start(): Promise { + const pullPolicy = resolvePullPolicy(this.pullPolicy); const client = await getContainerRuntimeClient(); - await client.image.pull(this.imageName, { - force: this.pullPolicy.shouldPull(), - platform: this.createOpts.platform, - }); + if (pullPolicy === "never") { + await client.image.inspect(this.imageName); + } else { + await client.image.pull(this.imageName, { + force: pullPolicy === "always", + platform: this.createOpts.platform, + }); + } if (this.beforeContainerCreated) { await this.beforeContainerCreated(); diff --git a/packages/testcontainers/src/utils/pull-policy.test.ts b/packages/testcontainers/src/utils/pull-policy.test.ts index b12b6f0ca..6b48734e1 100644 --- a/packages/testcontainers/src/utils/pull-policy.test.ts +++ b/packages/testcontainers/src/utils/pull-policy.test.ts @@ -1,11 +1,54 @@ -import { ImagePullPolicy, PullPolicy } from "./pull-policy"; +import { ImagePullPolicy, PullPolicy, resolvePullPolicy } from "./pull-policy"; test("default pull policy should return false", () => { - expect(PullPolicy.defaultPolicy().shouldPull()).toBe(false); + const policy = PullPolicy.defaultPolicy(); + expect(policy.shouldPull()).toBe(false); + expect(resolvePullPolicy(policy)).toBe("missing"); }); test("always pull policy should return true", () => { - expect(PullPolicy.alwaysPull().shouldPull()).toBe(true); + const policy = PullPolicy.alwaysPull(); + expect(policy.shouldPull()).toBe(true); + expect(resolvePullPolicy(policy)).toBe("always"); +}); + +test("never pull policy should forbid pulling", () => { + const policy = PullPolicy.neverPull(); + expect(policy.shouldPull()).toBe(false); + expect(policy.neverPull?.()).toBe(true); + expect(resolvePullPolicy(policy)).toBe("never"); +}); + +test.each([true, false])("should preserve a custom shouldPull result of %s", (shouldPull) => { + const policy: ImagePullPolicy = { shouldPull: () => shouldPull }; + expect(resolvePullPolicy(policy)).toBe(shouldPull ? "always" : "missing"); +}); + +test.each([true, false])("should allow shouldPull to return %s when neverPull is false", (shouldPull) => { + const policy: ImagePullPolicy = { shouldPull: () => shouldPull, neverPull: () => false }; + expect(resolvePullPolicy(policy)).toBe(shouldPull ? "always" : "missing"); +}); + +test("should support a custom never-pull policy", () => { + const policy: ImagePullPolicy = { shouldPull: () => false, neverPull: () => true }; + expect(resolvePullPolicy(policy)).toBe("never"); +}); + +test("should reject conflicting pull settings", () => { + const policy: ImagePullPolicy = { shouldPull: () => true, neverPull: () => true }; + expect(() => resolvePullPolicy(policy)).toThrow("Image pull policy cannot enable both shouldPull() and neverPull()"); +}); + +test("should evaluate shouldPull only once", () => { + const shouldPull = vi.fn().mockReturnValue(false); + expect(resolvePullPolicy({ shouldPull })).toBe("missing"); + expect(shouldPull).toHaveBeenCalledTimes(1); +}); + +test("should evaluate neverPull only once", () => { + const neverPull = vi.fn().mockReturnValue(true); + expect(resolvePullPolicy({ shouldPull: () => false, neverPull })).toBe("never"); + expect(neverPull).toHaveBeenCalledTimes(1); }); test("should be able to create a custom pull policy", () => { diff --git a/packages/testcontainers/src/utils/pull-policy.ts b/packages/testcontainers/src/utils/pull-policy.ts index 8e8fd4fb7..e068b8945 100644 --- a/packages/testcontainers/src/utils/pull-policy.ts +++ b/packages/testcontainers/src/utils/pull-policy.ts @@ -1,5 +1,6 @@ export interface ImagePullPolicy { shouldPull(): boolean; + neverPull?(): boolean; } class DefaultPullPolicy implements ImagePullPolicy { @@ -14,6 +15,29 @@ class AlwaysPullPolicy implements ImagePullPolicy { } } +class NeverPullPolicy implements ImagePullPolicy { + public neverPull(): boolean { + return true; + } + + public shouldPull(): boolean { + return false; + } +} + +type PullMode = "missing" | "always" | "never"; + +export function resolvePullPolicy(pullPolicy: ImagePullPolicy): PullMode { + const shouldPull = pullPolicy.shouldPull(); + if (pullPolicy.neverPull?.()) { + if (shouldPull) { + throw new Error("Image pull policy cannot enable both shouldPull() and neverPull()"); + } + return "never"; + } + return shouldPull ? "always" : "missing"; +} + export class PullPolicy { public static defaultPolicy(): ImagePullPolicy { return new DefaultPullPolicy(); @@ -22,4 +46,8 @@ export class PullPolicy { public static alwaysPull(): ImagePullPolicy { return new AlwaysPullPolicy(); } + + public static neverPull(): ImagePullPolicy { + return new NeverPullPolicy(); + } } diff --git a/packages/testcontainers/src/utils/test-helper.test.ts b/packages/testcontainers/src/utils/test-helper.test.ts index 372a4cbb4..7aef68572 100644 --- a/packages/testcontainers/src/utils/test-helper.test.ts +++ b/packages/testcontainers/src/utils/test-helper.test.ts @@ -28,4 +28,46 @@ describe("waitForDockerEvent", () => { await expect(waitPromise).resolves.toBeUndefined(); }); + + it.each([ + { type: "image", action: "pull", attribute: "name" }, + { type: "container", action: "start", attribute: "image" }, + ])("should filter $type events by image", async ({ type, action, attribute }) => { + const eventStream = new PassThrough(); + const image = "cristianrgreco/testcontainer:local"; + const waitPromise = waitForDockerEvent(eventStream, action, 1, image); + let hasResolved = false; + waitPromise.then(() => (hasResolved = true)); + + eventStream.write( + `${JSON.stringify({ Type: type, Action: action, Actor: { Attributes: { [attribute]: "other:local" } } })}\n` + ); + eventStream.write(`${JSON.stringify({ Type: type, Action: action })}\n`); + await Promise.resolve(); + expect(hasResolved).toBe(false); + + eventStream.write( + `${JSON.stringify({ Type: type, Action: action, Actor: { Attributes: { [attribute]: `docker.io/${image}` } } })}\n` + ); + await expect(waitPromise).resolves.toBeUndefined(); + }); + + it("should count only matching container starts", async () => { + const eventStream = new PassThrough(); + const image = "cristianrgreco/testcontainer:local"; + const waitPromise = waitForDockerEvent(eventStream, "start", 2, `docker.io/${image}`); + let hasResolved = false; + waitPromise.then(() => (hasResolved = true)); + + for (const eventImage of [image, "other:local"]) { + eventStream.write( + `${JSON.stringify({ Type: "container", Action: "start", Actor: { Attributes: { image: eventImage } } })}\n` + ); + } + await Promise.resolve(); + expect(hasResolved).toBe(false); + + eventStream.write(`${JSON.stringify({ Type: "container", Action: "start", Actor: { Attributes: { image } } })}\n`); + await expect(waitPromise).resolves.toBeUndefined(); + }); }); diff --git a/packages/testcontainers/src/utils/test-helper.ts b/packages/testcontainers/src/utils/test-helper.ts index af52146da..19a4d6dde 100644 --- a/packages/testcontainers/src/utils/test-helper.ts +++ b/packages/testcontainers/src/utils/test-helper.ts @@ -6,8 +6,8 @@ import { EOL, tmpdir } from "node:os"; import path from "node:path"; import { Readable } from "stream"; import { Agent, request } from "undici"; -import { IntervalRetry } from "../common"; -import { getContainerRuntimeClient } from "../container-runtime"; +import { IntervalRetry, RandomUuid } from "../common"; +import { getContainerRuntimeClient, ImageName } from "../container-runtime"; import { StartedDockerComposeEnvironment } from "../docker-compose-environment/started-docker-compose-environment"; import { GenericContainer } from "../generic-container/generic-container"; import { StartedTestContainer } from "../test-container"; @@ -128,11 +128,21 @@ export const getVolumeNames = async (): Promise => { return volumes.map((volume) => volume.Name); }; -export const waitForDockerEvent = async (eventStream: Readable, eventName: string, times = 1) => { +export const waitForDockerEvent = async (eventStream: Readable, eventName: string, times = 1, imageName?: string) => { + type DockerEvent = { + status?: string; + Action?: string; + Type?: string; + Actor?: { Attributes?: { name?: string; image?: string } }; + }; + + const stripDockerHubPrefix = (name?: string): string | undefined => name?.replace(/^docker\.io\//, ""); + + const expectedImage = stripDockerHubPrefix(imageName); let currentTimes = 0; let pendingData = ""; - const parseDockerEvent = (eventData: string): { status?: string; Action?: string } | undefined => { + const parseDockerEvent = (eventData: string): DockerEvent | undefined => { try { return JSON.parse(eventData); } catch { @@ -151,8 +161,11 @@ export const waitForDockerEvent = async (eventStream: Readable, eventName: strin for (const line of lines) { const event = parseDockerEvent(line); const action = event?.status ?? event?.Action; + const imageAttribute = event?.Type === "image" ? "name" : "image"; + const eventImage = event?.Actor?.Attributes?.[imageAttribute]; + const matchesImage = expectedImage === undefined || stripDockerHubPrefix(eventImage) === expectedImage; - if (action === eventName) { + if (action === eventName && matchesImage) { if (++currentTimes === times) { eventStream.off("data", onData); resolve(); @@ -172,6 +185,24 @@ export async function getImageLabelsByName(imageName: string): Promise<{ [label: return imageInfo.Config.Labels; } +export const createTempImageTag = async (source: string): Promise<{ name: string } & AsyncDisposable> => { + const client = await getContainerRuntimeClient(); + const sourceImage = ImageName.fromString(source); + await client.image.pull(sourceImage); + const image = new ImageName(sourceImage.registry, sourceImage.image, `testcontainers-${new RandomUuid().nextUuid()}`); + await client.container.dockerode.getImage(sourceImage.string).tag({ + repo: [image.registry, image.image].filter(Boolean).join("/"), + tag: image.tag, + }); + + return { + name: image.string, + [Symbol.asyncDispose]: async () => { + await deleteImageByName(image.string); + }, + }; +}; + export async function deleteImageByName(imageName: string): Promise { const dockerode = (await getContainerRuntimeClient()).container.dockerode; await dockerode.getImage(imageName).remove();