Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -131,6 +132,9 @@ export class DockerImageClient implements ImageClient {

async pull(imageName: ImageName, opts?: { force: boolean; platform: string | undefined }): Promise<void> {
try {
if (await useLocalImage(this.dockerode, imageName)) {
return;
}
if (!opts?.force && (await this.exists(imageName))) {
log.debug(`Image "${imageName.string}" already exists`);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +17,9 @@ export const pullImage = async (
options: PullImageOptions
): Promise<void> => {
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Dockerode from "dockerode";
import { ImageName } from "../image-name";

export async function useLocalImage(dockerode: Dockerode, imageName: ImageName): Promise<boolean> {
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;
}