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

feat: ImageBuilder and script for custom images #30615

Open
wants to merge 8 commits into
base: integration
Choose a base branch
from
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
20 changes: 0 additions & 20 deletions dev/cnf/dependabot/check_this_in_if_it_changes/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2601,26 +2601,6 @@
<artifactId>checker-compat-qual</artifactId>
<version>2.5.2</version>
</dependency>
<dependency>
<groupId>org.codehaus.jackson</groupId>
<artifactId>jackson-core-asl</artifactId>
<version>1.6.2</version>
</dependency>
<dependency>
<groupId>org.codehaus.jackson</groupId>
<artifactId>jackson-jaxrs</artifactId>
<version>1.6.2</version>
</dependency>
<dependency>
<groupId>org.codehaus.jackson</groupId>
<artifactId>jackson-mapper-asl</artifactId>
<version>1.6.2</version>
</dependency>
<dependency>
<groupId>org.codehaus.jackson</groupId>
<artifactId>jackson-xc</artifactId>
<version>1.6.2</version>
</dependency>
<dependency>
<groupId>org.codehaus.plexus</groupId>
<artifactId>plexus-utils</artifactId>
Expand Down
251 changes: 251 additions & 0 deletions dev/fattest.simplicity/src/componenttest/containers/ImageBuilder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
/*******************************************************************************
* 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 an alternative registry
private static final String BASE_IMAGE = "BASE_IMAGE";

// Image to build
private final DockerImageName image;

// Constructor - builder class
private ImageBuilder(DockerImageName image) {
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 the 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 the 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 attempt to pull the image from a registry,
* 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()
.orElseGet(() -> pullCached()
.orElseGet(() -> buildFromDockerfile()));
}

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

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(new RemoteDockerImage(image));
}
}

/*
* 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, "Did not attempt to pull cached image from localhost for image: " + 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 resourcePath = constructResourcePath(image);
String baseImage = findBaseImageFrom(resourcePath).asCanonicalNameString();

ImageFromDockerfile builtImage = new ImageFromDockerfile(image.asCanonicalNameString(), false)
.withFileFromClasspath(".", resourcePath)
.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:
* [registry | localhost]/openliberty/testcontainers/<image-name>:<image-version>
* @return the resource path in the form: /openliberty/testcontainers/<image-name>/<image-version>/
*/
private static String constructResourcePath(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
* given it's resource path, 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 resourcePath of the directory that contains a Dockerfile
* @return The substituted docker image of the BASE_IMAGE argument
*/
private static DockerImageName findBaseImageFrom(String resourcePath) {
final String BASE_IMAGE_PREFIX = "ARG BASE_IMAGE=\"";

/*
* Finds the resource directory on the classpath and will extract the directory 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(resourcePath).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 baseImageLine = dockerfileLines.filter(line -> line.startsWith("ARG BASE_IMAGE"))
.findFirst()
.orElseThrow(() -> new IllegalStateException(errorMessage));

String baseImageName = baseImageLine.substring(BASE_IMAGE_PREFIX.length(), baseImageLine.lastIndexOf('"'));

// NOTE: this is NOT the ImageBuilderSubstitutor
return ImageNameSubstitutor.instance().apply(DockerImageName.parse(baseImageName));
}

/**
* An ImageNameSubstitutor for images built by this outer class.
* Built images are not kept in a public registry and are typically cached locally
* or within an internal registry on the network.
*/
private static class ImageBuilderSubstitutor extends ImageNameSubstitutor {

private static final String INTERNAL_REGISTRY_PROP = "docker_registry.server";

// 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 are located
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;
}

// Hide instance method from parent class
// which will choose the ImageNameSubstitutor based on environment.
private static ImageBuilderSubstitutor instance;

public static synchronized ImageNameSubstitutor instance() {
if (Objects.isNull(instance)) {
instance = new ImageBuilderSubstitutor();
}
return instance;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,6 @@ public final class ImageVerifier {
_expectedImages.add(DockerImageName.parse(image));
}

//TODO remove once WebSphere Liberty is updated
_expectedImages.add(DockerImageName.parse("alpine:3.17"));

//Add images from the testcontainers project (tracked in fattest.simplicity/bnd.bnd)
for (String image : Arrays.asList("testcontainers/ryuk:0.9.0", "testcontainers/sshd:1.2.0", "testcontainers/vnc-recorder:1.3.0",
"public.ecr.aws/docker/library/alpine:3.17")) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*******************************************************************************
* 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);
}

// TODO write test when using an internal registry
}

@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().getDeclaredField("image");
image.setAccessible(true);
return (DockerImageName) image.get(builder);
}
}
Loading