Skip to content

Commit

Permalink
Add runImage option for image building
Browse files Browse the repository at this point in the history
This commit adds a runImage property to the Maven plugin build-image
goal and the Gradle bootBuildImage task. The property allows the user
to override the run image reference provided in the builder metadata
with an alternate run image. The runImage property can be specified
in the build file or on the command line.

Fixes gh-21534
  • Loading branch information
scottfrederick committed Jun 15, 2020
1 parent 025d7aa commit 6119d69
Show file tree
Hide file tree
Showing 27 changed files with 426 additions and 88 deletions.
Expand Up @@ -46,6 +46,8 @@ public class BuildRequest {

private final ImageReference builder;

private final ImageReference runImage;

private final Creator creator;

private final Map<String, String> env;
Expand All @@ -60,17 +62,20 @@ public class BuildRequest {
this.name = name.inTaggedForm();
this.applicationContent = applicationContent;
this.builder = DEFAULT_BUILDER;
this.runImage = null;
this.env = Collections.emptyMap();
this.cleanCache = false;
this.verboseLogging = false;
this.creator = Creator.withVersion("");
}

BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent, ImageReference builder,
Creator creator, Map<String, String> env, boolean cleanCache, boolean verboseLogging) {
ImageReference runImage, Creator creator, Map<String, String> env, boolean cleanCache,
boolean verboseLogging) {
this.name = name;
this.applicationContent = applicationContent;
this.builder = builder;
this.runImage = runImage;
this.creator = creator;
this.env = env;
this.cleanCache = cleanCache;
Expand All @@ -84,20 +89,29 @@ public class BuildRequest {
*/
public BuildRequest withBuilder(ImageReference builder) {
Assert.notNull(builder, "Builder must not be null");
builder = (builder.getDigest() != null) ? builder : builder.inTaggedForm();
return new BuildRequest(this.name, this.applicationContent, builder, this.creator, this.env, this.cleanCache,
this.verboseLogging);
return new BuildRequest(this.name, this.applicationContent, builder.inTaggedOrDigestForm(), this.runImage,
this.creator, this.env, this.cleanCache, this.verboseLogging);
}

/**
* Return a new {@link BuildRequest} with an updated builder.
* Return a new {@link BuildRequest} with an updated run image.
* @param runImageName the run image to use
* @return an updated build request
*/
public BuildRequest withRunImage(ImageReference runImageName) {
return new BuildRequest(this.name, this.applicationContent, this.builder, runImageName.inTaggedOrDigestForm(),
this.creator, this.env, this.cleanCache, this.verboseLogging);
}

/**
* Return a new {@link BuildRequest} with an updated creator.
* @param creator the new {@code Creator} to use
* @return an updated build request
*/
public BuildRequest withCreator(Creator creator) {
Assert.notNull(creator, "Creator must not be null");
return new BuildRequest(this.name, this.applicationContent, this.builder, creator, this.env, this.cleanCache,
this.verboseLogging);
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, creator, this.env,
this.cleanCache, this.verboseLogging);
}

/**
Expand All @@ -111,40 +125,40 @@ public BuildRequest withEnv(String name, String value) {
Assert.hasText(value, "Value must not be empty");
Map<String, String> env = new LinkedHashMap<>(this.env);
env.put(name, value);
return new BuildRequest(this.name, this.applicationContent, this.builder, this.creator,
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator,
Collections.unmodifiableMap(env), this.cleanCache, this.verboseLogging);
}

/**
* Return a new {@link BuildRequest} with an additional env variables.
* Return a new {@link BuildRequest} with additional env variables.
* @param env the additional variables
* @return an updated build request
*/
public BuildRequest withEnv(Map<String, String> env) {
Assert.notNull(env, "Env must not be null");
Map<String, String> updatedEnv = new LinkedHashMap<>(this.env);
updatedEnv.putAll(env);
return new BuildRequest(this.name, this.applicationContent, this.builder, this.creator,
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator,
Collections.unmodifiableMap(updatedEnv), this.cleanCache, this.verboseLogging);
}

/**
* Return a new {@link BuildRequest} with an specific clean cache settings.
* Return a new {@link BuildRequest} with an updated clean cache setting.
* @param cleanCache if the cache should be cleaned
* @return an updated build request
*/
public BuildRequest withCleanCache(boolean cleanCache) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.creator, this.env, cleanCache,
this.verboseLogging);
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
cleanCache, this.verboseLogging);
}

/**
* Return a new {@link BuildRequest} with an specific verbose logging settings.
* Return a new {@link BuildRequest} with an updated verbose logging setting.
* @param verboseLogging if verbose logging should be used
* @return an updated build request
*/
public BuildRequest withVerboseLogging(boolean verboseLogging) {
return new BuildRequest(this.name, this.applicationContent, this.builder, this.creator, this.env,
return new BuildRequest(this.name, this.applicationContent, this.builder, this.runImage, this.creator, this.env,
this.cleanCache, verboseLogging);
}

Expand Down Expand Up @@ -175,6 +189,14 @@ public ImageReference getBuilder() {
return this.builder;
}

/**
* Return the run image that should be used, if provided.
* @return the run image
*/
public ImageReference getRunImage() {
return this.runImage;
}

/**
* Return the {@link Creator} the builder should use.
* @return the {@code Creator}
Expand Down
Expand Up @@ -63,15 +63,12 @@ public void build(BuildRequest request) throws DockerEngineException, IOExceptio
Image builderImage = pullBuilder(request);
BuilderMetadata builderMetadata = BuilderMetadata.fromImage(builderImage);
BuildOwner buildOwner = BuildOwner.fromEnv(builderImage.getConfig().getEnv());
StackId stackId = StackId.fromImage(builderImage);
ImageReference runImageReference = getRunImageReference(builderMetadata.getStack());
Image runImage = pullRunImage(request, runImageReference);
assertHasExpectedStackId(runImage, stackId);
request = determineRunImage(request, builderImage, builderMetadata.getStack());
EphemeralBuilder builder = new EphemeralBuilder(buildOwner, builderImage, builderMetadata, request.getCreator(),
request.getEnv());
this.docker.image().load(builder.getArchive(), UpdateListener.none());
try {
executeLifecycle(request, runImageReference, builder);
executeLifecycle(request, builder);
}
finally {
this.docker.image().remove(builder.getName(), true);
Expand All @@ -87,29 +84,41 @@ private Image pullBuilder(BuildRequest request) throws IOException {
return builderImage;
}

private ImageReference getRunImageReference(Stack stack) {
private BuildRequest determineRunImage(BuildRequest request, Image builderImage, Stack builderStack)
throws IOException {
if (request.getRunImage() == null) {
ImageReference runImage = getRunImageReferenceForStack(builderStack);
request = request.withRunImage(runImage);
}
Image runImage = pullRunImage(request);
assertStackIdsMatch(runImage, builderImage);
return request;
}

private ImageReference getRunImageReferenceForStack(Stack stack) {
String name = stack.getRunImage().getImage();
Assert.state(StringUtils.hasText(name), "Run image must be specified");
return ImageReference.of(name).inTaggedForm();
Assert.state(StringUtils.hasText(name), "Run image must be specified in the builder image stack");
return ImageReference.of(name).inTaggedOrDigestForm();
}

private Image pullRunImage(BuildRequest request, ImageReference name) throws IOException {
Consumer<TotalProgressEvent> progressConsumer = this.log.pullingRunImage(request, name);
private Image pullRunImage(BuildRequest request) throws IOException {
ImageReference runImage = request.getRunImage();
Consumer<TotalProgressEvent> progressConsumer = this.log.pullingRunImage(request, runImage);
TotalProgressPullListener listener = new TotalProgressPullListener(progressConsumer);
Image image = this.docker.image().pull(name, listener);
Image image = this.docker.image().pull(runImage, listener);
this.log.pulledRunImage(request, image);
return image;
}

private void assertHasExpectedStackId(Image image, StackId stackId) {
StackId pulledStackId = StackId.fromImage(image);
Assert.state(pulledStackId.equals(stackId),
"Run image stack '" + pulledStackId + "' does not match builder stack '" + stackId + "'");
private void assertStackIdsMatch(Image runImage, Image builderImage) {
StackId runImageStackId = StackId.fromImage(runImage);
StackId builderImageStackId = StackId.fromImage(builderImage);
Assert.state(runImageStackId.equals(builderImageStackId),
"Run image stack '" + runImageStackId + "' does not match builder stack '" + builderImageStackId + "'");
}

private void executeLifecycle(BuildRequest request, ImageReference runImageReference, EphemeralBuilder builder)
throws IOException {
try (Lifecycle lifecycle = new Lifecycle(this.log, this.docker, request, runImageReference, builder)) {
private void executeLifecycle(BuildRequest request, EphemeralBuilder builder) throws IOException {
try (Lifecycle lifecycle = new Lifecycle(this.log, this.docker, request, builder)) {
lifecycle.execute();
}
}
Expand Down
Expand Up @@ -48,8 +48,6 @@ class Lifecycle implements Closeable {

private final BuildRequest request;

private final ImageReference runImageReference;

private final EphemeralBuilder builder;

private final LifecycleVersion lifecycleVersion;
Expand All @@ -73,15 +71,12 @@ class Lifecycle implements Closeable {
* @param log build output log
* @param docker the Docker API
* @param request the request to process
* @param runImageReference a reference to run image that should be used
* @param builder the ephemeral builder used to run the phases
*/
Lifecycle(BuildLog log, DockerApi docker, BuildRequest request, ImageReference runImageReference,
EphemeralBuilder builder) {
Lifecycle(BuildLog log, DockerApi docker, BuildRequest request, EphemeralBuilder builder) {
this.log = log;
this.docker = docker;
this.request = request;
this.runImageReference = runImageReference;
this.builder = builder;
this.lifecycleVersion = LifecycleVersion.parse(builder.getBuilderMetadata().getLifecycle().getVersion());
this.platformVersion = ApiVersion.parse(builder.getBuilderMetadata().getLifecycle().getApi().getPlatform());
Expand Down Expand Up @@ -125,7 +120,7 @@ private Phase createPhase() {
phase.withLogLevelArg();
phase.withArgs("-app", Directory.APPLICATION);
phase.withArgs("-platform", Directory.PLATFORM);
phase.withArgs("-run-image", this.runImageReference);
phase.withArgs("-run-image", this.request.getRunImage());
phase.withArgs("-layers", Directory.LAYERS);
phase.withArgs("-cache-dir", Directory.CACHE);
phase.withArgs("-launch-cache", Directory.LAUNCH_CACHE);
Expand Down
Expand Up @@ -27,6 +27,7 @@
* A reference to a Docker image of the form {@code "imagename[:tag|@digest]"}.
*
* @author Phillip Webb
* @author Scott Frederick
* @since 2.3.0
* @see ImageName
* @see <a href=
Expand Down Expand Up @@ -152,7 +153,19 @@ public ImageReference withDigest(String digest) {
*/
public ImageReference inTaggedForm() {
Assert.state(this.digest == null, () -> "Image reference '" + this + "' cannot contain a digest");
return new ImageReference(this.name, (this.tag != null) ? this.tag : LATEST, this.digest);
return new ImageReference(this.name, (this.tag != null) ? this.tag : LATEST, null);
}

/**
* Return an {@link ImageReference} containing either a tag or a digest. If neither
* the digest or the tag has been defined then tag {@code latest} is used.
* @return the image reference in tagged or digest form
*/
public ImageReference inTaggedOrDigestForm() {
if (this.digest != null) {
return this;
}
return inTaggedForm();
}

/**
Expand Down
Expand Up @@ -82,7 +82,6 @@ void forJarFileWhenJarFileIsMissingThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> BuildRequest.forJarFile(new File(this.tempDir, "missing.jar")))
.withMessage("JarFile must exist");

}

@Test
Expand All @@ -106,6 +105,21 @@ void withBuilderWhenHasDigestUpdatesBuilder() throws IOException {
"docker.io/spring/builder:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
}

@Test
void withRunImageUpdatesRunImage() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"))
.withRunImage(ImageReference.of("example.com/custom/run-image:latest"));
assertThat(request.getRunImage().toString()).isEqualTo("example.com/custom/run-image:latest");
}

@Test
void withRunImageWhenHasDigestUpdatesRunImage() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")).withRunImage(ImageReference
.of("example.com/custom/run-image:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"));
assertThat(request.getRunImage().toString()).isEqualTo(
"example.com/custom/run-image:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
}

@Test
void withCreatorUpdatesCreator() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
Expand Down
Expand Up @@ -106,6 +106,47 @@ void buildInvokesBuilderWithDefaultImageTags() throws Exception {
verify(docker.image()).remove(archive.getValue().getTag(), true);
}

@Test
void buildInvokesBuilderWithRunImageInDigestForm() throws Exception {
TestPrintStream out = new TestPrintStream();
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image-with-run-image-digest.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of(
"docker.io/cloudfoundry/run:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d")),
any())).willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
BuildRequest request = getTestRequest();
builder.build(request);
assertThat(out.toString()).contains("Running creator");
assertThat(out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class);
verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true);
}

@Test
void buildInvokesBuilderWithRunImageFromRequest() throws Exception {
TestPrintStream out = new TestPrintStream();
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image.json");
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("example.com/custom/run:latest")), any()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
BuildRequest request = getTestRequest().withRunImage(ImageReference.of("example.com/custom/run:latest"));
builder.build(request);
assertThat(out.toString()).contains("Running creator");
assertThat(out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
ArgumentCaptor<ImageArchive> archive = ArgumentCaptor.forClass(ImageArchive.class);
verify(docker.image()).load(archive.capture(), any());
verify(docker.image()).remove(archive.getValue().getTag(), true);
}

@Test
void buildWhenStackIdDoesNotMatchThrowsException() throws Exception {
TestPrintStream out = new TestPrintStream();
Expand Down Expand Up @@ -175,8 +216,7 @@ private DockerApi mockDockerApiLifecycleError() throws IOException {
private BuildRequest getTestRequest() {
TarArchive content = mock(TarArchive.class);
ImageReference name = ImageReference.of("my-application");
BuildRequest request = BuildRequest.of(name, (owner) -> content);
return request;
return BuildRequest.of(name, (owner) -> content);
}

private Image loadImage(String name) throws IOException {
Expand Down
Expand Up @@ -150,7 +150,7 @@ private DockerApi mockDockerApi() {
private BuildRequest getTestRequest() {
TarArchive content = mock(TarArchive.class);
ImageReference name = ImageReference.of("my-application");
return BuildRequest.of(name, (owner) -> content);
return BuildRequest.of(name, (owner) -> content).withRunImage(ImageReference.of("cloudfoundry/run"));
}

private Lifecycle createLifecycle() throws IOException {
Expand All @@ -159,8 +159,7 @@ private Lifecycle createLifecycle() throws IOException {

private Lifecycle createLifecycle(BuildRequest request) throws IOException {
EphemeralBuilder builder = mockEphemeralBuilder();
return new TestLifecycle(BuildLog.to(this.out), this.docker, request, ImageReference.of("cloudfoundry/run"),
builder);
return new TestLifecycle(BuildLog.to(this.out), this.docker, request, builder);
}

private EphemeralBuilder mockEphemeralBuilder() throws IOException {
Expand Down Expand Up @@ -208,9 +207,8 @@ private IOConsumer<ContainerConfig> withExpectedConfig(String name) {

static class TestLifecycle extends Lifecycle {

TestLifecycle(BuildLog log, DockerApi docker, BuildRequest request, ImageReference runImageReference,
EphemeralBuilder builder) {
super(log, docker, request, runImageReference, builder);
TestLifecycle(BuildLog log, DockerApi docker, BuildRequest request, EphemeralBuilder builder) {
super(log, docker, request, builder);
}

@Override
Expand Down

0 comments on commit 6119d69

Please sign in to comment.