Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for building a container from context archive #671

Closed
wants to merge 1 commit into from
Closed
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
24 changes: 24 additions & 0 deletions docs/features/images.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,27 @@ const container = await GenericContainer
.withCache(false)
.build();
```

### Dynamic build context

If you would like to send a build context that you created in code (maybe you have a dynamic Dockerfile), you can send
the build context as a `NodeJS.ReadableStream` since the Docker Daemon accepts it as a _tar_ file. You can use the
[tar-fs](https://www.npmjs.com/package/tar-fs) (or [tar-stream](https://www.npmjs.com/package/tar-stream)) package to
create a custom dynamic context.

```javascript
const tar = require('tar-stream');

const tarStream = tar.pack();
tarStream.entry({ name: 'alpine.Dockerfile' },
`
FROM alpine:latest
CMD ["sleep", "infinity"]
`
);
tarStream.finalize();

const container = await GenericContainer
.fromContextArchive(tarStream, 'alpine.Dockerfile')
.build();
```
12 changes: 8 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/testcontainers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
"@types/proper-lockfile": "^4.1.3",
"@types/properties-reader": "^2.1.2",
"@types/tar-fs": "^2.0.3",
"@types/tmp": "^0.2.5"
"@types/tar-stream": "^3.1.3",
"@types/tmp": "^0.2.5",
"tar-stream": "^3.1.6"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tar-stream shouldn't be in dependencies 🤔 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only used it in the tests to generate a test tar stream. It is not used in the package's production code.

}
}
Original file line number Diff line number Diff line change
@@ -1,38 +1,24 @@
import Dockerode, { ImageBuildOptions } from "dockerode";
import byline from "byline";
import tar from "tar-fs";
import path from "path";
import { existsSync, promises as fs } from "fs";
import dockerIgnore from "@balena/dockerignore";
import { getAuthConfig } from "../../auth/get-auth-config";
import { ImageName } from "../../image-name";
import { ImageClient } from "./image-client";
import AsyncLock from "async-lock";
import { log, buildLog, pullLog } from "../../../common";
import stream from "stream";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the node stream api https://nodejs.org/api/stream.html#stream right?
sometimes when I dont read it as

const stream = require('node:stream'); 

has this doubt 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is. I can change the import name with node:stream, but I don't know the difference.


export class DockerImageClient implements ImageClient {
private readonly existingImages = new Set<string>();
private readonly imageExistsLock = new AsyncLock();

constructor(protected readonly dockerode: Dockerode, protected readonly indexServerAddress: string) {}

async build(context: string, opts: ImageBuildOptions): Promise<void> {
async build(context: stream.Readable, opts: ImageBuildOptions): Promise<void> {
try {
log.debug(`Building image "${opts.t}" with context "${context}"...`);
Copy link
Collaborator

@cristianrgreco cristianrgreco Nov 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reasoning behind moving this logic to the GenericContainerBuilder? I liked the idea that the DockerImageClient can be used standalone and supports .dockerignore files out of the box. After this change this is no longer the case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand your point.

I decided to raise the filtering to a higher abstraction level, recognizing that the .dockerignore filtering is a specific instance of passing a tar stream to the container runtime client. This approach streamlines the code and renders the DockerImageClient agnostic to the underlying filesystem, enhancing its versatility.

At the time of implementation, I faced two options:

  1. Introduce a method in GenericContainerBuilder and DockerImageClient for passing the stream directly to the container runtime client without .dockerignore filtering. This would create an additional flow in both classes.

  2. Maintain the DockerImageClient agnostic to the filesystem (not utilizing tar-fs) and keep the API straightforward by passing a stream to the container runtime client. Since the filtered stream is a subset of the passed stream, this approach avoids needing two separate APIs in both the client and the GenericContainerBuilder class.

const isDockerIgnored = await this.createIsDockerIgnoredFunction(context);
const tarStream = tar.pack(context, {
ignore: (aPath) => {
const relativePath = path.relative(context, aPath);
if (relativePath === opts.dockerfile) {
return false;
} else {
return isDockerIgnored(relativePath);
}
},
});
log.debug(`Building image "${opts.t}"...`);
await new Promise<void>((resolve) => {
this.dockerode
.buildImage(tarStream, opts)
.buildImage(context, opts)
.then((stream) => byline(stream))
.then((stream) => {
stream.setEncoding("utf-8");
Expand All @@ -44,27 +30,13 @@ export class DockerImageClient implements ImageClient {
stream.on("end", () => resolve());
});
});
log.debug(`Built image "${opts.t}" with context "${context}"`);
log.debug(`Built image "${opts.t}"`);
} catch (err) {
log.error(`Failed to build image: ${err}`);
throw err;
}
}

private async createIsDockerIgnoredFunction(context: string): Promise<(path: string) => boolean> {
const dockerIgnoreFilePath = path.join(context, ".dockerignore");
if (!existsSync(dockerIgnoreFilePath)) {
return () => false;
}

const dockerIgnorePatterns = await fs.readFile(dockerIgnoreFilePath, { encoding: "utf-8" });
const instance = dockerIgnore({ ignorecase: false });
instance.add(dockerIgnorePatterns);
const filter = instance.createFilter();

return (aPath: string) => !filter(aPath);
}

async exists(imageName: ImageName): Promise<boolean> {
return this.imageExistsLock.acquire(imageName.string, async () => {
if (this.existingImages.has(imageName.string)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ImageBuildOptions } from "dockerode";
import { ImageName } from "../../image-name";

export interface ImageClient {
build(context: string, opts: ImageBuildOptions): Promise<void>;
build(context: NodeJS.ReadableStream, opts: ImageBuildOptions): Promise<void>;
pull(imageName: ImageName, opts?: { force: boolean }): Promise<void>;
exists(imageName: ImageName): Promise<boolean>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { getAuthConfig, getContainerRuntimeClient, ImageName } from "../containe
import { getReaper } from "../reaper/reaper";
import { getDockerfileImages } from "../utils/dockerfile-parser";
import { createLabels, LABEL_TESTCONTAINERS_SESSION_ID } from "../utils/labels";
import tar from "tar-fs";
import { existsSync, promises as fs } from "fs";
import dockerIgnore from "@balena/dockerignore";
import Dockerode from "dockerode";

export type BuildOptions = {
deleteOnExit: boolean;
Expand All @@ -19,12 +23,12 @@ export class GenericContainerBuilder {
private target?: string;

constructor(
private readonly context: string,
private readonly context: NodeJS.ReadableStream | string,
private readonly dockerfileName: string,
private readonly uuid: Uuid = new RandomUuid()
) {}

public withBuildArgs(buildArgs: BuildArgs): GenericContainerBuilder {
public withBuildArgs(buildArgs: BuildArgs): this {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks to do the return this pattern consistent here 👏

this.buildArgs = buildArgs;
return this;
}
Expand All @@ -44,6 +48,12 @@ export class GenericContainerBuilder {
return this;
}

/**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really really really really appreciate these comments for our methods and functions but, since they are not anywhere in the library, I don't know if it would be convenient to start doing it. 🤔

* Build the image.
*
* @param image - The image name to tag the built image with.
* @param options - Options for the build. Defaults to `{ deleteOnExit: true }`.
*/
public async build(
image = `localhost/${this.uuid.nextUuid()}:${this.uuid.nextUuid()}`,
options: BuildOptions = { deleteOnExit: true }
Expand All @@ -52,26 +62,49 @@ export class GenericContainerBuilder {
const reaper = await getReaper(client);

const imageName = ImageName.fromString(image);
const dockerfile = path.resolve(this.context, this.dockerfileName);

const imageNames = await getDockerfileImages(dockerfile, this.buildArgs);
const registryConfig = await this.getRegistryConfig(client.info.containerRuntime.indexServerAddress, imageNames);
const labels = createLabels();
if (options.deleteOnExit) {
labels[LABEL_TESTCONTAINERS_SESSION_ID] = reaper.sessionId;
}

log.info(`Building Dockerfile "${dockerfile}" as image "${imageName}"...`);
await client.image.build(this.context, {
let contextStream: NodeJS.ReadableStream;
const imageBuildOptions: Dockerode.ImageBuildOptions = {
t: imageName.string,
dockerfile: this.dockerfileName,
buildargs: this.buildArgs,
pull: this.pullPolicy ? "true" : undefined,
nocache: !this.cache,
registryconfig: registryConfig,
labels,
target: this.target,
});
};

if (typeof this.context !== "string") {
contextStream = this.context;
} else {
// Get the registry config for the images in the Dockerfile
const dockerfile = path.resolve(this.context, this.dockerfileName);
const imageNames = await getDockerfileImages(dockerfile, this.buildArgs);
imageBuildOptions.registryconfig = await this.getRegistryConfig(
client.info.containerRuntime.indexServerAddress,
imageNames
);

// Create a tar stream of the context directory, excluding the files that are ignored by .dockerignore
const dockerignoreFilter = await newDockerignoreFilter(this.context);
contextStream = tar.pack(this.context, {
ignore: (aPath) => {
const relativePath = path.relative(<string>this.context, aPath);
if (relativePath === this.dockerfileName) {
return false;
} else {
return dockerignoreFilter(relativePath);
}
},
});
}

log.info(`Building Dockerfile "${this.dockerfileName}" as image "${imageName.string}"...`);
await client.image.build(contextStream, imageBuildOptions);

const container = new GenericContainer(imageName.string);
if (!(await client.image.exists(imageName))) {
Expand Down Expand Up @@ -105,3 +138,17 @@ export class GenericContainerBuilder {
.reduce((prev, next) => ({ ...prev, ...next }), {} as RegistryConfig);
}
}

async function newDockerignoreFilter(context: string): Promise<(path: string) => boolean> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only to curiosity in your approach, why newDockerignoreFilter function is not part from class GenericContainerBuilder as a private method take into account that you are only using it to this class responsabilities?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe class methods should access or modify the object instance.

In this case, newDockerignoreFilter does not access nor modify any object fields. It can be seen as a factory method. It is a function that, given a context path, returns a .dockerignore based filter function.

const dockerIgnoreFilePath = path.join(context, ".dockerignore");
if (!existsSync(dockerIgnoreFilePath)) {
return () => false;
}

const dockerIgnorePatterns = await fs.readFile(dockerIgnoreFilePath, { encoding: "utf-8" });
const instance = dockerIgnore({ ignorecase: false });
instance.add(dockerIgnorePatterns);
const filter = instance.createFilter();

return (aPath: string) => !filter(aPath);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import fs from "fs";
import path from "path";
import tar from "tar-stream";
import { GenericContainer } from "./generic-container";
import { Wait } from "../wait-strategies/wait";
import { PullPolicy } from "../utils/pull-policy";
Expand Down Expand Up @@ -103,3 +105,27 @@ describe("GenericContainer Dockerfile", () => {
await startedContainer.stop();
});
});

describe("GenericContainer fromContextArchive", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks to add tests 🎉

jest.setTimeout(180_000);

const fixtures = path.resolve(__dirname, "..", "..", "fixtures", "docker");

it("should build and start", async () => {
const fixturesCtx = path.resolve(fixtures, "docker");

// Generate a context stream with a Dockerfile named "alpine.Dockerfile"
const tarStream = tar.pack();
tarStream.entry({ name: "alpine.Dockerfile" }, fs.readFileSync(path.join(fixturesCtx, "Dockerfile")));
tarStream.entry({ name: "index.js" }, fs.readFileSync(path.join(fixturesCtx, "index.js")));
tarStream.entry({ name: "test.txt" }, "hello world");
tarStream.finalize();

const container = await GenericContainer.fromContextArchive(tarStream, "alpine.Dockerfile").build();
const startedContainer = await container.withExposedPorts(8080).start();

await checkContainerIsHealthy(startedContainer);

await startedContainer.stop();
});
});
19 changes: 19 additions & 0 deletions packages/testcontainers/src/generic-container/generic-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,29 @@ import { mapInspectResult } from "../utils/map-inspect-result";
const reusableContainerCreationLock = new AsyncLock();

export class GenericContainer implements TestContainer {
/**
* Create a docker image from a Dockerfile.
*
* @param context - The build context directory path.
* @param dockerfileName - The name of the Dockerfile. Default is `Dockerfile`.
*/
public static fromDockerfile(context: string, dockerfileName = "Dockerfile"): GenericContainerBuilder {
return new GenericContainerBuilder(context, dockerfileName);
}

/**
* Create a docker image from a context archive stream.
*
* @param context - The build context archive stream.
* @param dockerfileName - The name of the Dockerfile. Default is `Dockerfile`.
*/
public static fromContextArchive(
context: NodeJS.ReadableStream,
dockerfileName = "Dockerfile"
): GenericContainerBuilder {
return new GenericContainerBuilder(context, dockerfileName);
}

protected createOpts: ContainerCreateOptions;
protected hostConfig: HostConfig;

Expand Down