From 42c767586ddd17df00038f315bb5605111589bb4 Mon Sep 17 00:00:00 2001 From: Long Ho Date: Sun, 13 Sep 2026 13:46:11 +0000 Subject: [PATCH] Add a global never-pull image policy --- docs/configuration.md | 12 +++ .../clients/image/docker-image-client.test.ts | 73 +++++++++++++++++++ .../clients/image/docker-image-client.ts | 4 + .../src/container-runtime/utils/pull-image.ts | 4 + .../utils/use-local-image.ts | 19 +++++ 5 files changed, 112 insertions(+) create mode 100644 packages/testcontainers/src/container-runtime/clients/image/docker-image-client.test.ts create mode 100644 packages/testcontainers/src/container-runtime/utils/use-local-image.ts diff --git a/docs/configuration.md b/docs/configuration.md index f920b3985..013d8426c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -46,3 +46,15 @@ Configuration of Testcontainers and its behaviours: | TESTCONTAINERS_REUSE_ENABLE | true | Enable reusable containers | | TESTCONTAINERS_RYUK_VERBOSE | true | Sets RYUK_VERBOSE env var in ryuk container | | TESTCONTAINERS_RYUK_RECONNECTION_TIMEOUT | 30s | Sets RYUK_RECONNECTION_TIMEOUT env var in ryuk container | + +## Disable image pulls + +Set `TESTCONTAINERS_PULL_POLICY=never` to require locally available images when +starting containers, including helpers such as Ryuk and SSHd. Preload the required +images into the selected Docker daemon before running tests. If an image cannot be +inspected locally, startup fails before registry authentication or an image pull. +This setting takes precedence over `withPullPolicy`, including `alwaysPull()`. +When unset, existing pull behavior is unchanged. + +This restricts Testcontainers' image-pull operations; it does not disable container +network access or control pulls performed by Docker builds or Docker Compose. diff --git a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.test.ts b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.test.ts new file mode 100644 index 000000000..4bb941470 --- /dev/null +++ b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.test.ts @@ -0,0 +1,73 @@ +import { randomUUID } from "crypto"; +import Dockerode from "dockerode"; +import { Readable } from "stream"; +import { getAuthConfig } from "../../auth/get-auth-config"; +import { ImageName } from "../../image-name"; +import { pullImage } from "../../utils/pull-image"; +import { DockerImageClient } from "./docker-image-client"; + +vi.mock("../../auth/get-auth-config", () => ({ getAuthConfig: vi.fn() })); + +describe.sequential.each(["client", "helper"])("%s image pull policy", (implementation) => { + function setup() { + const inspect = vi.fn().mockResolvedValue({ Id: "local-image" }); + const pull = vi.fn().mockImplementation(async () => Readable.from([])); + const dockerode = { getImage: () => ({ inspect }), pull } as unknown as Dockerode; + const client = new DockerImageClient(dockerode, "https://index.docker.io/v1/"); + const imageName = ImageName.fromString(`testcontainers/ryuk:0.14.0-${randomUUID()}`); + return { + inspect, + pull, + run: (force = false) => + implementation === "client" + ? client.pull(imageName, { force, platform: undefined }) + : pullImage(dockerode, "https://index.docker.io/v1/", { imageName, force }), + }; + } + + it("uses a local image even when forced pulling is requested", async () => { + vi.stubEnv("TESTCONTAINERS_PULL_POLICY", "never"); + const { run, pull } = setup(); + await run(true); + expect(pull).not.toHaveBeenCalled(); + expect(getAuthConfig).not.toHaveBeenCalled(); + }); + + it("fails before registry authentication or pulling when the image is missing", async () => { + vi.stubEnv("TESTCONTAINERS_PULL_POLICY", "never"); + const { run, inspect, pull } = setup(); + inspect.mockRejectedValue(new Error("No such image")); + await expect(run()).rejects.toThrow(/testcontainers\/ryuk:0.14.0.*TESTCONTAINERS_PULL_POLICY=never/); + expect(pull).not.toHaveBeenCalled(); + expect(getAuthConfig).not.toHaveBeenCalled(); + }); + + it("checks the daemon again if a previously available image disappears", async () => { + vi.stubEnv("TESTCONTAINERS_PULL_POLICY", "never"); + const { run, inspect, pull } = setup(); + await run(); + inspect.mockRejectedValue(new Error("No such image")); + await expect(run()).rejects.toThrow("TESTCONTAINERS_PULL_POLICY=never"); + expect(pull).not.toHaveBeenCalled(); + expect(getAuthConfig).not.toHaveBeenCalled(); + }); + + it("preserves the inspection failure as the cause", async () => { + vi.stubEnv("TESTCONTAINERS_PULL_POLICY", "never"); + const { run, inspect, pull } = setup(); + const cause = new Error("Docker permission denied"); + inspect.mockRejectedValue(cause); + await expect(run()).rejects.toMatchObject({ cause }); + expect(pull).not.toHaveBeenCalled(); + expect(getAuthConfig).not.toHaveBeenCalled(); + }); + + it("still pulls missing images by default", async () => { + vi.stubEnv("TESTCONTAINERS_PULL_POLICY", undefined); + const { run, inspect, pull } = setup(); + inspect.mockRejectedValue(new Error("No such image")); + await run(); + expect(pull).toHaveBeenCalledOnce(); + expect(getAuthConfig).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts index 4c6ab884c..8b38fbd87 100644 --- a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts @@ -8,6 +8,7 @@ import tar from "tar-fs"; import { buildLog, log, pullLog } from "../../../common"; import { getAuthConfig } from "../../auth/get-auth-config"; import { ImageName } from "../../image-name"; +import { useLocalImage } from "../../utils/use-local-image"; import { ImageClient } from "./image-client"; export class DockerImageClient implements ImageClient { @@ -131,6 +132,9 @@ export class DockerImageClient implements ImageClient { async pull(imageName: ImageName, opts?: { force: boolean; platform: string | undefined }): Promise { try { + if (await useLocalImage(this.dockerode, imageName)) { + return; + } if (!opts?.force && (await this.exists(imageName))) { log.debug(`Image "${imageName.string}" already exists`); return; diff --git a/packages/testcontainers/src/container-runtime/utils/pull-image.ts b/packages/testcontainers/src/container-runtime/utils/pull-image.ts index 129927457..93bb94b9f 100644 --- a/packages/testcontainers/src/container-runtime/utils/pull-image.ts +++ b/packages/testcontainers/src/container-runtime/utils/pull-image.ts @@ -4,6 +4,7 @@ import { log, pullLog } from "../../common"; import { getAuthConfig } from "../auth/get-auth-config"; import { ImageName } from "../image-name"; import { imageExists } from "./image-exists"; +import { useLocalImage } from "./use-local-image"; export type PullImageOptions = { imageName: ImageName; @@ -16,6 +17,9 @@ export const pullImage = async ( options: PullImageOptions ): Promise => { try { + if (await useLocalImage(dockerode, options.imageName)) { + return; + } if (!options.force && (await imageExists(dockerode, options.imageName))) { log.debug(`Not pulling image "${options.imageName.string}" as it already exists`); return; diff --git a/packages/testcontainers/src/container-runtime/utils/use-local-image.ts b/packages/testcontainers/src/container-runtime/utils/use-local-image.ts new file mode 100644 index 000000000..3b4b17ffa --- /dev/null +++ b/packages/testcontainers/src/container-runtime/utils/use-local-image.ts @@ -0,0 +1,19 @@ +import Dockerode from "dockerode"; +import { ImageName } from "../image-name"; + +export async function useLocalImage(dockerode: Dockerode, imageName: ImageName): Promise { + if (process.env.TESTCONTAINERS_PULL_POLICY !== "never") { + return false; + } + + // Bypass the existence cache: an image may have been removed since the last check. + try { + await dockerode.getImage(imageName.string).inspect(); + } catch (cause) { + throw new Error( + `Cannot use local image "${imageName.string}" with TESTCONTAINERS_PULL_POLICY=never; preload it before starting containers`, + { cause } + ); + } + return true; +}