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
14 changes: 14 additions & 0 deletions docs/features/compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/features/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/features/images.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
services:
container:
image: ${TEST_IMAGE}
build:
context: ../docker/docker
ports:
- 8080
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
services:
container:
image: ${TEST_IMAGE}
pull_policy: always
ports:
- 8080
other:
image: ${OTHER_IMAGE}
ports:
- 8080
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
}
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -62,6 +62,14 @@ export class GenericContainerBuilder {
image = `localhost/${this.uuid.nextUuid()}:${this.uuid.nextUuid()}`,
options: BuildOptions = { deleteOnExit: true }
): Promise<GenericContainer> {
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);

Expand Down Expand Up @@ -90,7 +98,7 @@ export class GenericContainerBuilder {
version: this.buildkit ? "2" : "1",
};

if (this.pullPolicy.shouldPull()) {
if (pullPolicy === "always") {
buildOptions.pull = true;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down
Loading