Skip to content

Commit 3e28454

Browse files
committed
added buildKit support
1 parent 090e94f commit 3e28454

File tree

6 files changed

+218
-74
lines changed

6 files changed

+218
-74
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
FROM node:10-alpine as default
2+
3+
MAINTAINER Cristian Greco
4+
5+
EXPOSE 8080
6+
7+
RUN apk add --no-cache dumb-init
8+
9+
RUN --mount=type=cache,id=npm,target=/root/.npm npm init -y \
10+
&& npm install [email protected]
11+
12+
COPY index.js .
13+
14+
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
15+
CMD ["node", "index.js"]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const express = require("express");
2+
3+
const app = express();
4+
const port = 8080;
5+
6+
app.get("/hello-world", (req, res) => {
7+
res.status(200).send("hello-world");
8+
});
9+
10+
app.get("/env", (req, res) => {
11+
res.status(200).json(process.env);
12+
});
13+
14+
app.get("/cmd", (req, res) => {
15+
res.status(200).json(process.argv);
16+
});
17+
18+
app.listen(port, () => console.log(`Listening on port ${port}`));

packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,24 @@ export class DockerImageClient implements ImageClient {
111111
throw err;
112112
}
113113
}
114+
115+
async load(file: string | NodeJS.ReadableStream): Promise<void> {
116+
try {
117+
log.debug(`Loading image...`);
118+
119+
const stream = await this.dockerode.loadImage(file);
120+
await new Promise<void>((resolve) => {
121+
byline(stream).on("data", (line) => {
122+
if (pullLog.enabled()) {
123+
pullLog.trace(line);
124+
}
125+
});
126+
stream.on("end", resolve);
127+
});
128+
log.debug(`Loaded image`);
129+
} catch (err) {
130+
log.error(`Failed to load image: ${err}`);
131+
throw err;
132+
}
133+
}
114134
}

packages/testcontainers/src/container-runtime/clients/image/image-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export interface ImageClient {
55
build(context: string, opts: ImageBuildOptions): Promise<void>;
66
pull(imageName: ImageName, opts?: { force: boolean }): Promise<void>;
77
exists(imageName: ImageName): Promise<boolean>;
8+
load(file: string | NodeJS.ReadableStream): Promise<void>;
89
}

packages/testcontainers/src/generic-container/generic-container-builder.ts

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import path from "path";
33
import { GenericContainer } from "./generic-container";
44
import { ImagePullPolicy, PullPolicy } from "../utils/pull-policy";
55
import { log, RandomUuid, Uuid } from "../common";
6-
import { getAuthConfig, getContainerRuntimeClient, ImageName } from "../container-runtime";
6+
import { getAuthConfig, getContainerRuntimeClient, ImageName, type ContainerRuntimeClient } from "../container-runtime";
77
import { getReaper } from "../reaper/reaper";
88
import { getDockerfileImages } from "../utils/dockerfile-parser";
99
import { createLabels, LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
10+
import { Wait } from "../wait-strategies/wait";
11+
import tar from "tar-stream";
12+
import { pipeline } from "stream/promises";
1013

1114
export type BuildOptions = {
1215
deleteOnExit: boolean;
@@ -17,6 +20,7 @@ export class GenericContainerBuilder {
1720
private pullPolicy: ImagePullPolicy = PullPolicy.defaultPolicy();
1821
private cache = true;
1922
private target?: string;
23+
private useBuildKit = false;
2024

2125
constructor(
2226
private readonly context: string,
@@ -44,6 +48,11 @@ export class GenericContainerBuilder {
4448
return this;
4549
}
4650

51+
public withBuildKit(useBuildKit = true): this {
52+
this.useBuildKit = useBuildKit;
53+
return this;
54+
}
55+
4756
public async build(
4857
image = `localhost/${this.uuid.nextUuid()}:${this.uuid.nextUuid()}`,
4958
options: BuildOptions = { deleteOnExit: true }
@@ -62,16 +71,21 @@ export class GenericContainerBuilder {
6271
}
6372

6473
log.info(`Building Dockerfile "${dockerfile}" as image "${imageName.string}"...`);
65-
await client.image.build(this.context, {
66-
t: imageName.string,
67-
dockerfile: this.dockerfileName,
68-
buildargs: this.buildArgs,
69-
pull: this.pullPolicy ? "true" : undefined,
70-
nocache: !this.cache,
71-
registryconfig: registryConfig,
72-
labels,
73-
target: this.target,
74-
});
74+
75+
if (this.useBuildKit) {
76+
await this.buildWithBuildKit(client, imageName.string, registryConfig, labels);
77+
} else {
78+
await client.image.build(this.context, {
79+
t: imageName.string,
80+
dockerfile: this.dockerfileName,
81+
buildargs: this.buildArgs,
82+
pull: this.pullPolicy.shouldPull() ? "true" : undefined,
83+
nocache: !this.cache,
84+
registryconfig: registryConfig,
85+
labels,
86+
target: this.target,
87+
});
88+
}
7589

7690
const container = new GenericContainer(imageName.string);
7791
if (!(await client.image.exists(imageName))) {
@@ -80,6 +94,71 @@ export class GenericContainerBuilder {
8094
return Promise.resolve(container);
8195
}
8296

97+
private async buildWithBuildKit(
98+
client: ContainerRuntimeClient,
99+
image: string,
100+
registryConfig: RegistryConfig,
101+
labels: Record<string, string>
102+
) {
103+
const command = [
104+
"build",
105+
"--frontend",
106+
"dockerfile.v0",
107+
"--local",
108+
"context=/work",
109+
"--local",
110+
"dockerfile=/work",
111+
"--opt",
112+
`filename=./${this.dockerfileName}`,
113+
"--output",
114+
`type=docker,name=${image},dest=/tmp/image.tar`,
115+
];
116+
117+
for (const [key, value] of Object.entries(this.buildArgs)) {
118+
command.push("--opt", `build-arg:${key}=${value}`);
119+
}
120+
121+
if (this.pullPolicy.shouldPull()) {
122+
command.push("--opt", "image-resolve-mode=pull");
123+
}
124+
125+
if (!this.cache) {
126+
command.push("--opt", "no-cache=true");
127+
}
128+
129+
for (const [registry, auth] of Object.entries(registryConfig)) {
130+
command.push("--opt", `registry.auth=${registry}=${auth.username}:${auth.password}`);
131+
}
132+
133+
for (const [key, value] of Object.entries(labels)) {
134+
command.push("--opt", `label:${key}=${value}`);
135+
}
136+
137+
if (this.target) {
138+
command.push("--opt", `target=${this.target}`);
139+
}
140+
141+
const buildKit = await new GenericContainer("moby/buildkit:v0.13.2")
142+
.withPrivilegedMode()
143+
.withBindMounts([
144+
{ source: this.context, target: "/work" },
145+
{ source: "/tmp/testcontainers_buildcache", target: "/var/lib/buildkit" },
146+
])
147+
.withEntrypoint(["buildctl-daemonless.sh"])
148+
.withCommand(command)
149+
.withWaitStrategy(Wait.forOneShotStartup())
150+
.start();
151+
152+
const archiveStream = await buildKit.copyArchiveFromContainer("/tmp/image.tar");
153+
const extractStream = tar.extract();
154+
155+
extractStream.on("entry", (_header, imageStream, next) => {
156+
client.image.load(imageStream).then(next).catch(next);
157+
});
158+
159+
await pipeline(archiveStream, extractStream);
160+
}
161+
83162
private async getRegistryConfig(indexServerAddress: string, imageNames: ImageName[]): Promise<RegistryConfig> {
84163
const authConfigs: AuthConfig[] = [];
85164

packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts

Lines changed: 74 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -20,86 +20,97 @@ describe("GenericContainer Dockerfile", () => {
2020
const uuidGen = new RandomUuid();
2121
const fixtures = path.resolve(__dirname, "..", "..", "fixtures", "docker");
2222

23-
it("should build and start", async () => {
24-
const context = path.resolve(fixtures, "docker");
25-
const container = await GenericContainer.fromDockerfile(context).build();
26-
const startedContainer = await container.withExposedPorts(8080).start();
23+
describe.each([false, true])("with buildKit=%s", (useBuildKit) => {
24+
it("should build and start", async () => {
25+
const context = path.resolve(fixtures, "docker");
26+
const container = await GenericContainer.fromDockerfile(context).withBuildKit(useBuildKit).build();
27+
const startedContainer = await container.withExposedPorts(8080).start();
2728

28-
await checkContainerIsHealthy(startedContainer);
29+
await checkContainerIsHealthy(startedContainer);
2930

30-
await startedContainer.stop();
31-
});
32-
33-
it("should have a session ID label to be cleaned up by the Reaper", async () => {
34-
const context = path.resolve(fixtures, "docker");
35-
const imageName = `${uuidGen.nextUuid()}:${uuidGen.nextUuid()}`;
36-
37-
await GenericContainer.fromDockerfile(context).build(imageName);
38-
39-
const client = await getContainerRuntimeClient();
40-
const reaper = await getReaper(client);
41-
const imageLabels = await getImageLabelsByName(imageName);
42-
expect(imageLabels[LABEL_TESTCONTAINERS_SESSION_ID]).toEqual(reaper.sessionId);
31+
await startedContainer.stop();
32+
});
4333

44-
await deleteImageByName(imageName);
45-
});
34+
it("should have a session ID label to be cleaned up by the Reaper", async () => {
35+
const context = path.resolve(fixtures, "docker");
36+
const imageName = `${uuidGen.nextUuid()}:${uuidGen.nextUuid()}`;
4637

47-
it("should not have a session ID label when delete on exit set to false", async () => {
48-
const context = path.resolve(fixtures, "docker");
49-
const imageName = `${uuidGen.nextUuid()}:${uuidGen.nextUuid()}`;
38+
await GenericContainer.fromDockerfile(context).withBuildKit(useBuildKit).build(imageName);
5039

51-
await GenericContainer.fromDockerfile(context).build(imageName, { deleteOnExit: false });
40+
const client = await getContainerRuntimeClient();
41+
const reaper = await getReaper(client);
42+
const imageLabels = await getImageLabelsByName(imageName);
43+
expect(imageLabels[LABEL_TESTCONTAINERS_SESSION_ID]).toEqual(reaper.sessionId);
5244

53-
const imageLabels = await getImageLabelsByName(imageName);
54-
expect(imageLabels[LABEL_TESTCONTAINERS_SESSION_ID]).toBeUndefined();
45+
await deleteImageByName(imageName);
46+
});
5547

56-
await deleteImageByName(imageName);
57-
});
48+
it("should not have a session ID label when delete on exit set to false", async () => {
49+
const context = path.resolve(fixtures, "docker");
50+
const imageName = `${uuidGen.nextUuid()}:${uuidGen.nextUuid()}`;
5851

59-
// https://github.com/containers/podman/issues/17779
60-
if (!process.env.CI_PODMAN) {
61-
it("should use pull policy", async () => {
62-
const dockerfile = path.resolve(fixtures, "docker");
63-
const containerSpec = GenericContainer.fromDockerfile(dockerfile).withPullPolicy(PullPolicy.alwaysPull());
52+
await GenericContainer.fromDockerfile(context)
53+
.withBuildKit(useBuildKit)
54+
.build(imageName, { deleteOnExit: false });
6455

65-
await containerSpec.build();
66-
const dockerEventStream = await getDockerEventStream();
67-
const dockerPullEventPromise = waitForDockerEvent(dockerEventStream, "pull");
68-
await containerSpec.build();
69-
await dockerPullEventPromise;
56+
const imageLabels = await getImageLabelsByName(imageName);
57+
expect(imageLabels[LABEL_TESTCONTAINERS_SESSION_ID]).toBeUndefined();
7058

71-
dockerEventStream.destroy();
59+
await deleteImageByName(imageName);
7260
});
73-
}
7461

75-
it("should build and start with custom file name", async () => {
76-
const context = path.resolve(fixtures, "docker-with-custom-filename");
77-
const container = await GenericContainer.fromDockerfile(context, "Dockerfile-A").build();
78-
const startedContainer = await container.withExposedPorts(8080).start();
79-
80-
await checkContainerIsHealthy(startedContainer);
81-
82-
await startedContainer.stop();
83-
});
62+
// https://github.com/containers/podman/issues/17779
63+
if (!process.env.CI_PODMAN && !useBuildKit) {
64+
it("should use pull policy", async () => {
65+
const dockerfile = path.resolve(fixtures, "docker");
66+
const containerSpec = GenericContainer.fromDockerfile(dockerfile)
67+
.withBuildKit(useBuildKit)
68+
.withPullPolicy(PullPolicy.alwaysPull());
69+
70+
await containerSpec.build();
71+
const dockerEventStream = await getDockerEventStream();
72+
const dockerPullEventPromise = waitForDockerEvent(dockerEventStream, "pull");
73+
await containerSpec.build();
74+
await dockerPullEventPromise;
75+
76+
dockerEventStream.destroy();
77+
});
78+
}
79+
80+
it("should build and start with custom file name", async () => {
81+
const context = path.resolve(fixtures, "docker-with-custom-filename");
82+
const container = await GenericContainer.fromDockerfile(context, "Dockerfile-A")
83+
.withBuildKit(useBuildKit)
84+
.build();
85+
const startedContainer = await container.withExposedPorts(8080).start();
86+
87+
await checkContainerIsHealthy(startedContainer);
88+
89+
await startedContainer.stop();
90+
});
8491

85-
it("should set build arguments", async () => {
86-
const context = path.resolve(fixtures, "docker-with-buildargs");
87-
const container = await GenericContainer.fromDockerfile(context).withBuildArgs({ VERSION: "10-alpine" }).build();
88-
const startedContainer = await container.withExposedPorts(8080).start();
92+
it("should set build arguments", async () => {
93+
const context = path.resolve(fixtures, "docker-with-buildargs");
94+
const container = await GenericContainer.fromDockerfile(context)
95+
.withBuildKit(useBuildKit)
96+
.withBuildArgs({ VERSION: "10-alpine" })
97+
.build();
98+
const startedContainer = await container.withExposedPorts(8080).start();
8999

90-
await checkContainerIsHealthy(startedContainer);
100+
await checkContainerIsHealthy(startedContainer);
91101

92-
await startedContainer.stop();
93-
});
102+
await startedContainer.stop();
103+
});
94104

95-
it("should exit immediately and stop without exception", async () => {
96-
const message = "This container will exit immediately.";
97-
const context = path.resolve(fixtures, "docker-exit-immediately");
98-
const container = await GenericContainer.fromDockerfile(context).build();
99-
const startedContainer = await container.withWaitStrategy(Wait.forLogMessage(message)).start();
105+
it("should exit immediately and stop without exception", async () => {
106+
const message = "This container will exit immediately.";
107+
const context = path.resolve(fixtures, "docker-exit-immediately");
108+
const container = await GenericContainer.fromDockerfile(context).withBuildKit(useBuildKit).build();
109+
const startedContainer = await container.withWaitStrategy(Wait.forLogMessage(message)).start();
100110

101-
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
111+
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
102112

103-
await startedContainer.stop();
113+
await startedContainer.stop();
114+
});
104115
});
105116
});

0 commit comments

Comments
 (0)