Skip to content

Commit

Permalink
feat: introduce image builder for custom images
Browse files Browse the repository at this point in the history
  • Loading branch information
KyleAure committed Jan 17, 2025
1 parent 3422c95 commit ac3a808
Show file tree
Hide file tree
Showing 2 changed files with 299 additions and 0 deletions.
232 changes: 232 additions & 0 deletions dev/fattest.simplicity/src/componenttest/containers/ImageBuilder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/*******************************************************************************
* Copyright (c) 2025 IBM Corporation and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*******************************************************************************/
package componenttest.containers;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;

import org.testcontainers.images.PullPolicy;
import org.testcontainers.images.RemoteDockerImage;
import org.testcontainers.images.builder.ImageFromDockerfile;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.ImageNameSubstitutor;
import org.testcontainers.utility.MountableFile;

import com.ibm.websphere.simplicity.log.Log;

/**
* This builder class is an extension of {@link org.testcontainers.images.builder.ImageFromDockerfile}
* and is intended to allow developers of Open Liberty the ability to create custom images from a Dockerfile.
*
* This class will is optimized to find or build the image using the following priority:
* - If the image is cached on the docker host, use it.
* - Otherwise, if the image is cached in the registry, pull it, and use it.
* - Otherwise, build the image and cache it on the docker host.
*
* TODO make builder public once build pipeline is finalized
*/
class ImageBuilder {

private static final Class<?> c = ImageBuilder.class;

// The --build-arg necessary to overwrite the default BASE_IMAGE in the Dockerfile
// with the mirrored image in artifactory
private static final String BASE_IMAGE = "BASE_IMAGE";

// Image to build
private final DockerImageName image;

// Constructor
private ImageBuilder(DockerImageName image) {
//builder class
this.image = image;
}

/**
* The image to build.
*
* The Dockerfile with instructions on how to build this image must be saved in source control in directory
* io.openliberty.org.testcontainers/resources/openliberty/testcontainers/<image-name>/<image-version>/Dockerfile
*
* Note: The resulting image will be cached with name "localhost/openliberty/testcontainers/<image-name>:<image-version>"
* therefore, you must update the image version whenever a change is made to the corresponding Dockerfile.
*
* @param customImage the image to build in format "<image-name>:<image-version>" or "openliberty/testcontainers/<image-name>:<image-version>"
*
* @return instance of ImageBuilder
*/
public static ImageBuilder build(String customImage) {
Objects.requireNonNull(customImage);

return new ImageBuilder(ImageBuilderSubstitutor.instance().apply(DockerImageName.parse(customImage)));
}

// Add future configuration methods here

/**
* Termination point of this builder class.
*
* We will first attempt to find a cached version of the image,
* if unsuccessful, we will then build the image from the Dockerfile.
*
* @return RemoteDockerImage that points to a cached or built image.
*/
public RemoteDockerImage get() {
return getCached().orElse(pullCached().orElse(buildFromDockerfile()));
}

/*
* Helper method, attempts to find a cached version of the image.
*/
private Optional<RemoteDockerImage> getCached() {
final String m = "getLocalCache";

RemoteDockerImage cachedImage = new RemoteDockerImage(image);

if (PullPolicy.defaultPolicy().shouldPull(image)) {
Log.info(c, m, "Unable to find cached image " + image.asCanonicalNameString());
return Optional.empty();
} else {
Log.info(c, m, "Found cached image " + image.asCanonicalNameString());
return Optional.of(cachedImage);
}
}

/*
* Helper method, attempt to pull a cached version from the a registry.
*/
private Optional<RemoteDockerImage> pullCached() {
final String m = "pullCached";

if (image.getRegistry().equalsIgnoreCase("localhost")) {
Log.info(c, m, "Assumed we cannot pull image from " + image.asCanonicalNameString());
return Optional.empty();
}

RemoteDockerImage cachedImage = new RemoteDockerImage(image);

try {
cachedImage.get();
Log.info(c, m, "Found pullable image " + image.asCanonicalNameString());
return Optional.of(cachedImage);
} catch (Exception e) {
Log.info(c, m, "Unable to find pullable image " + image.asCanonicalNameString());
return Optional.empty();
}
}

/*
* Helper method, constructs an image from a Dockerfile
*/
private RemoteDockerImage buildFromDockerfile() {
String resource = constructResource(image);
String baseImage = findBaseImageFrom(resource).asCanonicalNameString();

ImageFromDockerfile builtImage = new ImageFromDockerfile(image.asCanonicalNameString(), false)
.withFileFromClasspath(".", resource)
.withBuildArg(BASE_IMAGE, baseImage);

return new RemoteDockerImage(builtImage);
}

/**
* Helper method, constructs a resource path to the directory that holds the
* Dockerfile and supporting files that define this image.
*
*
* @param image The name of this image in the form: openliberty/testcontainers/<image-name>:<image-version>
* @return the resource path in the form: /openliberty/testcontainers/<image-name>/<image-version>/
*/
private static String constructResource(DockerImageName image) {
StringBuffer buffer = new StringBuffer();
buffer.append("/");
buffer.append(image.getRepository()).append("/");
buffer.append(image.getVersionPart()).append("/");

return buffer.toString();
}

/**
* Helper method, searches for the Dockerfile on the classpath,
* and attempts to find the line:
* - ARG BASE_IMAGE="[base-image-of-docker-file]"
*
* Once found, run the BASE_IMAGE through the ImageNameSubstitutor
* and return the DockerImageName result.
*
* @param resource the resource path of the Dockerfile
* @return The substituted docker image of the BASE_IMAGE argument
*/
private static DockerImageName findBaseImageFrom(String resource) {
/*
* Finds the Dockerfile on classpath and will extract the file to a temporary location so we can read it.
* This will be done during the image build step anyway so this is just front-loading that work for our benefit.
*/
String resourceDir = MountableFile.forClasspathResource(resource).getResolvedPath();

Stream<String> dockerfileLines;
try {
dockerfileLines = Files.readAllLines(Paths.get(resourceDir, "Dockerfile")).stream();
} catch (IOException e) {
throw new RuntimeException("Could not read or find Dockerfile in " + resourceDir, e);
}

String errorMessage = "The Dockerfile did not contain a BASE_IMAGE argument declaration. "
+ "This is required to allow us to pull and substitute the BASE_IMAGE using the ImageNameSubstitutor.";

String baseImageName = dockerfileLines.filter(line -> line.startsWith("ARG BASE_IMAGE"))
.findFirst()
.orElseThrow(() -> new IllegalStateException(errorMessage));

return ImageNameSubstitutor.instance().apply(DockerImageName.parse(baseImageName));
}

/**
* A ImageNameSubstitutor for images built by this outer class
*/
private static class ImageBuilderSubstitutor extends ImageNameSubstitutor {

// TODO replace with the finalized property expected on our build systems
private static final String INTERNAL_REGISTRY_PROP = "io.openliberty.internal.registry";

// Ensures when we look for cached images Docker only attempt to find images
// locally or from an internally configured registry.
private static final String REGISTRY = System.getProperty(INTERNAL_REGISTRY_PROP, "localhost");

// The repository where all Open Liberty images will be cached
private static final String REPOSITORY_PREFIX = "openliberty/testcontainers/";

@Override
public DockerImageName apply(final DockerImageName original) {
Objects.requireNonNull(original);

if (!original.getRegistry().isEmpty()) {
throw new IllegalArgumentException("DockerImageName with the registry " + original.getRegistry() + " cannot be substituted with registry " + REGISTRY);
}

if (original.getRepository().startsWith(REPOSITORY_PREFIX)) {
return original.withRegistry(REGISTRY);
} else {
return original.withRepository(REPOSITORY_PREFIX + original.getRepository()).withRegistry(REGISTRY);
}
}

@Override
protected String getDescription() {
return "ImageBuilderSubstitutor with registry " + REGISTRY;
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*******************************************************************************
* Copyright (c) 2025 IBM Corporation and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*******************************************************************************/
package componenttest.containers;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;

import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

import org.junit.Test;
import org.testcontainers.utility.DockerImageName;

/**
* Unit tests for {@link ImageBuilder}
*/
public class ImageBuilderTest {

@Test
public void imageBuilderSubstitutorTest() throws Exception {
final HashMap<String, DockerImageName> expectedImageMap = new HashMap<>();
expectedImageMap.put("postgres-init:17.0-alpine",
DockerImageName.parse("localhost/openliberty/testcontainers/postgres-init:17.0-alpine"));
expectedImageMap.put("openliberty/testcontainers/postgres-init:17.0-alpine",
DockerImageName.parse("localhost/openliberty/testcontainers/postgres-init:17.0-alpine"));

for (Map.Entry<String, DockerImageName> entry : expectedImageMap.entrySet()) {
DockerImageName actual = getImage(ImageBuilder.build(entry.getKey()));
DockerImageName expected = entry.getValue();

assertEquals("expected " + expected.asCanonicalNameString() + " but was " + actual.asCanonicalNameString(),
expected, actual);
}
}

@Test
public void imageBuilderSubstitutorErrorTest() {
try {
ImageBuilder.build(null);
fail("Should have thrown NullPointerException");
} catch (NullPointerException npe) {
//pass
}

try {
ImageBuilder.build("quay.io/testcontainers/ryuk:1.0.0");
fail("Should have thrown IllegalArgumentException");
} catch (IllegalArgumentException iae) {
//pass
}

}

private DockerImageName getImage(ImageBuilder builder) throws Exception {
Field image = builder.getClass().getField("image");
image.setAccessible(true);
return (DockerImageName) image.get(builder);
}
}

0 comments on commit ac3a808

Please sign in to comment.