Add cloud native buildpack module

Add a Java implementation of the buildpacks.io specification allowing
projects to be packaged into OCI containers. The `builder` class
provides a Java equivalent of `pack build` command and is based on
the `pack` CLI Go code published at https://github.com/buildpacks/pack.

Closes gh-19828
pull/19835/head
Phillip Webb 5 years ago
parent 7fe79f3574
commit aa1954717c

@ -10,7 +10,7 @@
xmlns:setup.p2="http://www.eclipse.org/oomph/setup/p2/1.0" xmlns:setup.p2="http://www.eclipse.org/oomph/setup/p2/1.0"
xmlns:setup.workingsets="http://www.eclipse.org/oomph/setup/workingsets/1.0" xmlns:setup.workingsets="http://www.eclipse.org/oomph/setup/workingsets/1.0"
xmlns:workingsets="http://www.eclipse.org/oomph/workingsets/1.0" xmlns:workingsets="http://www.eclipse.org/oomph/workingsets/1.0"
xsi:schemaLocation="http://www.eclipse.org/oomph/setup/jdt/1.0 https://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/JDT.ecore http://www.eclipse.org/buildship/oomph/1.0 https://raw.githubusercontent.com/eclipse/buildship/master/org.eclipse.buildship.oomph/model/GradleImport-1.0.ecore http://www.eclipse.org/oomph/predicates/1.0 https://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/Predicates.ecore http://www.eclipse.org/oomph/setup/workingsets/1.0 https://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/SetupWorkingSets.ecore http://www.eclipse.org/oomph/workingsets/1.0 https://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/WorkingSets.ecore" xsi:schemaLocation="http://www.eclipse.org/oomph/setup/jdt/1.0 http://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/JDT.ecore http://www.eclipse.org/buildship/oomph/1.0 https://raw.githubusercontent.com/eclipse/buildship/master/org.eclipse.buildship.oomph/model/GradleImport-1.0.ecore http://www.eclipse.org/oomph/predicates/1.0 http://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/Predicates.ecore http://www.eclipse.org/oomph/setup/workingsets/1.0 http://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/SetupWorkingSets.ecore http://www.eclipse.org/oomph/workingsets/1.0 http://git.eclipse.org/c/oomph/org.eclipse.oomph.git/plain/setups/models/WorkingSets.ecore"
name="spring.boot.2.3.x" name="spring.boot.2.3.x"
label="Spring Boot 2.3.x"> label="Spring Boot 2.3.x">
<setupTask <setupTask
@ -127,7 +127,7 @@
name="spring-boot-tools"> name="spring-boot-tools">
<predicate <predicate
xsi:type="predicates:NamePredicate" xsi:type="predicates:NamePredicate"
pattern="spring-boot-(tools|antlib|configuration-.*|loader|.*-tools|.*-plugin|autoconfigure-processor)"/> pattern="spring-boot-(tools|antlib|configuration-.*|loader|.*-tools|.*-plugin|autoconfigure-processor|cloudnativebuildpack)"/>
</workingSet> </workingSet>
<workingSet <workingSet
name="spring-boot-starters"> name="spring-boot-starters">

@ -41,6 +41,7 @@ include 'spring-boot-project:spring-boot-dependencies'
include 'spring-boot-project:spring-boot-parent' include 'spring-boot-project:spring-boot-parent'
include 'spring-boot-project:spring-boot-tools:spring-boot-antlib' include 'spring-boot-project:spring-boot-tools:spring-boot-antlib'
include 'spring-boot-project:spring-boot-tools:spring-boot-autoconfigure-processor' include 'spring-boot-project:spring-boot-tools:spring-boot-autoconfigure-processor'
include 'spring-boot-project:spring-boot-tools:spring-boot-cloudnativebuildpack'
include 'spring-boot-project:spring-boot-tools:spring-boot-configuration-metadata' include 'spring-boot-project:spring-boot-tools:spring-boot-configuration-metadata'
include 'spring-boot-project:spring-boot-tools:spring-boot-configuration-processor' include 'spring-boot-project:spring-boot-tools:spring-boot-configuration-processor'
include 'spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin' include 'spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin'

@ -0,0 +1,28 @@
plugins {
id 'java-library'
id 'org.springframework.boot.conventions'
id 'org.springframework.boot.deployed'
id 'org.springframework.boot.internal-dependency-management'
}
description = 'Spring Boot Cloud Native Buildpack'
dependencies {
api platform(project(':spring-boot-project:spring-boot-parent'))
api 'com.fasterxml.jackson.core:jackson-databind'
api 'com.fasterxml.jackson.module:jackson-module-parameter-names'
api 'net.java.dev.jna:jna-platform'
api 'org.apache.commons:commons-compress:1.19'
api 'org.apache.httpcomponents:httpclient'
api 'org.springframework:spring-core'
testImplementation project(':spring-boot-project:spring-boot-tools:spring-boot-test-support')
testImplementation 'com.jayway.jsonpath:json-path'
testImplementation 'org.assertj:assertj-core'
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.hamcrest:hamcrest'
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.mockito:mockito-core'
testImplementation 'org.mockito:mockito-junit-jupiter'
testImplementation 'org.skyscreamer:jsonassert'
}

@ -0,0 +1,96 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.util.List;
import java.util.function.Consumer;
import org.springframework.boot.cloudnativebuildpack.docker.LogUpdateEvent;
import org.springframework.boot.cloudnativebuildpack.docker.TotalProgressEvent;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.cloudnativebuildpack.docker.type.VolumeName;
/**
* Base class for {@link BuildLog} implementations.
*
* @author Phillip Webb
* @since 2.3.0
*/
public abstract class AbstractBuildLog implements BuildLog {
@Override
public void start(BuildRequest request) {
log("Building image '" + request.getName() + "'");
log();
}
@Override
public Consumer<TotalProgressEvent> pullingBuilder(BuildRequest request, ImageReference imageReference) {
return getProgressConsumer(" > Pulling builder image '" + imageReference + "'");
}
@Override
public void pulledBulder(BuildRequest request, Image image) {
log(" > Pulled builder image '" + getDigest(image) + "'");
}
@Override
public Consumer<TotalProgressEvent> pullingRunImage(BuildRequest request, ImageReference imageReference) {
return getProgressConsumer(" > Pulling run image '" + imageReference + "'");
}
@Override
public void pulledRunImage(BuildRequest request, Image image) {
log(" > Pulled run image '" + getDigest(image) + "'");
}
@Override
public void executingLifecycle(BuildRequest request, LifecycleVersion version, VolumeName buildCacheVolume) {
log(" > Executing lifecycle version " + version);
log(" > Using build cache volume '" + buildCacheVolume + "'");
}
@Override
public Consumer<LogUpdateEvent> runningPhase(BuildRequest request, String name) {
log();
log(" > Running " + name);
String prefix = String.format(" %-14s", "[" + name + "] ");
return (event) -> log(prefix + event);
}
@Override
public void executedLifecycle(BuildRequest request) {
log();
log("Successfully built image '" + request.getName() + "'");
log();
}
private String getDigest(Image image) {
List<String> digests = image.getDigests();
return (digests.isEmpty() ? "" : digests.get(0));
}
protected void log() {
log("");
}
protected abstract void log(String message);
protected abstract Consumer<TotalProgressEvent> getProgressConsumer(String message);
}

@ -0,0 +1,135 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.util.Assert;
/**
* API Version number comprised a major and minor value.
*
* @author Phillip Webb
*/
final class ApiVersion {
/**
* The platform API version supported by this release.
*/
static final ApiVersion PLATFORM = new ApiVersion(0, 1);
private static final Pattern PATTERN = Pattern.compile("^v?(\\d+)\\.(\\d*)$");
private final int major;
private final int minor;
private ApiVersion(int major, int minor) {
this.major = major;
this.minor = minor;
}
/**
* Return the major version number.
* @return the major version
*/
int getMajor() {
return this.major;
}
/**
* Return the minor version number.
* @return the minor version
*/
int getMinor() {
return this.minor;
}
/**
* Assert that this API version supports the specified version.
* @param other the version to check against
* @see #supports(ApiVersion)
*/
void assertSupports(ApiVersion other) {
if (!supports(other)) {
throw new IllegalStateException(
"Version '" + other + "' is not supported by this version ('" + this + "')");
}
}
/**
* Returns if this API version supports the given version. A {@code 0.x} matches only
* the same version number. A 1.x or higher release matches when the versions have the
* same major version and a minor that is equal or greater.
* @param other the version to check against
* @return of the specified API is supported
* @see #assertSupports(ApiVersion)
*/
boolean supports(ApiVersion other) {
if (equals(other)) {
return true;
}
if (this.major == 0 || this.major != other.major) {
return false;
}
return this.minor >= other.minor;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
ApiVersion other = (ApiVersion) obj;
return (this.major == other.major) && (this.minor == other.minor);
}
@Override
public int hashCode() {
return this.major * 31 + this.minor;
}
@Override
public String toString() {
return "v" + this.major + "." + this.minor;
}
/**
* Factory method to parse a string into an {@link ApiVersion} instance.
* @param value the value to parse.
* @return the corresponding {@link ApiVersion}
* @throws IllegalArgumentException if the value could not be parsed
*/
static ApiVersion parse(String value) {
Assert.hasText(value, "Value must not be empty");
Matcher matcher = PATTERN.matcher(value);
Assert.isTrue(matcher.matches(), "Malformed version number '" + value + "'");
try {
int major = Integer.parseInt(matcher.group(1));
int minor = Integer.parseInt(matcher.group(2));
return new ApiVersion(major, minor);
}
catch (NumberFormatException ex) {
throw new IllegalArgumentException("Malformed version number '" + value + "'", ex);
}
}
}

@ -0,0 +1,113 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.PrintStream;
import java.util.function.Consumer;
import org.springframework.boot.cloudnativebuildpack.docker.LogUpdateEvent;
import org.springframework.boot.cloudnativebuildpack.docker.TotalProgressEvent;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.cloudnativebuildpack.docker.type.VolumeName;
/**
* Callback interface used to provide {@link Builder} output logging.
*
* @author Phillip Webb
* @since 2.3.0
* @see #toSystemOut()
*/
public interface BuildLog {
/**
* Log that a build is starting.
* @param request the build request
*/
void start(BuildRequest request);
/**
* Log that the builder image is being pulled.
* @param request the build request
* @param imageReference the builder image reference
* @return a consumer for progress update events
*/
Consumer<TotalProgressEvent> pullingBuilder(BuildRequest request, ImageReference imageReference);
/**
* Log that the builder image has been pulled.
* @param request the build request
* @param image the builder image that was pulled
*/
void pulledBulder(BuildRequest request, Image image);
/**
* Log that a run image is being pulled.
* @param request the build request
* @param imageReference the run image reference
* @return a consumer for progress update events
*/
Consumer<TotalProgressEvent> pullingRunImage(BuildRequest request, ImageReference imageReference);
/**
* Log that a run image has been pulled.
* @param request the build request
* @param image the run image that was pulled
*/
void pulledRunImage(BuildRequest request, Image image);
/**
* Log that the lifecycle is executing.
* @param request the build request
* @param version the lifecyle version
* @param buildCacheVolume the name of the build cache volume in use
*/
void executingLifecycle(BuildRequest request, LifecycleVersion version, VolumeName buildCacheVolume);
/**
* Log that a specific phase is running.
* @param request the build request
* @param name the name of the phase
* @return a consumer for log updates
*/
Consumer<LogUpdateEvent> runningPhase(BuildRequest request, String name);
/**
* Log that the lifecycle has executed.
* @param request the build request
*/
void executedLifecycle(BuildRequest request);
/**
* Factory method that returns a {@link BuildLog} the outputs to {@link System#out}.
* @return a build log instance that logs to system out
*/
static BuildLog toSystemOut() {
return to(System.out);
}
/**
* Factory method that returns a {@link BuildLog} the outputs to a given
* {@link PrintStream}.
* @param out the print stream used to output the log
* @return a build log instance that logs to the given print stream
*/
static BuildLog to(PrintStream out) {
return new PrintStreamBuildLog(out);
}
}

@ -0,0 +1,100 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.util.Map;
import org.springframework.boot.cloudnativebuildpack.io.Owner;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* The {@link Owner} that should perform the build.
*
* @author Phillip Webb
*/
class BuildOwner implements Owner {
private static final String USER_PROPERTY_NAME = "CNB_USER_ID";
private static final String GROUP_PROPERTY_NAME = "CNB_GROUP_ID";
private final long uid;
private final long gid;
BuildOwner(Map<String, String> env) {
this.uid = getValue(env, USER_PROPERTY_NAME);
this.gid = getValue(env, GROUP_PROPERTY_NAME);
}
BuildOwner(long uid, long gid) {
this.uid = uid;
this.gid = gid;
}
private long getValue(Map<String, String> env, String name) {
String value = env.get(name);
Assert.state(StringUtils.hasText(value), "Missing '" + name + "' value from the builder environment");
try {
return Long.parseLong(value);
}
catch (NumberFormatException ex) {
throw new IllegalStateException("Malformed '" + name + "' value '" + value + "' in the builder environment",
ex);
}
}
@Override
public long getUid() {
return this.uid;
}
@Override
public long getGid() {
return this.gid;
}
@Override
public String toString() {
return this.uid + "/" + this.gid;
}
/**
* Factory method to create the {@link BuildOwner} by inspecting the image env for
* {@code CNB_USER_ID}/{@code CNB_GROUP_ID} variables.
* @param env the env to parse
* @return a {@link BuildOwner} instance extracted from the env
* @throws IllegalStateException if the env does not contain the correct CNB variables
*/
static BuildOwner fromEnv(Map<String, String> env) {
Assert.notNull(env, "Env must not be null");
return new BuildOwner(env);
}
/**
* Factory method to create a new {@link BuildOwner} with specified user/group
* identifier.
* @param uid the user identifier
* @param gid the group identifier
* @return a new {@link BuildOwner} instance
*/
static BuildOwner of(long uid, long gid) {
return new BuildOwner(uid, gid);
}
}

@ -0,0 +1,220 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.File;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Function;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.cloudnativebuildpack.io.Owner;
import org.springframework.boot.cloudnativebuildpack.io.TarArchive;
import org.springframework.util.Assert;
/**
* A build request to be handled by the {@link Builder}.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class BuildRequest {
private static final ImageReference DEFAULT_BUILDER = ImageReference.of("cloudfoundry/cnb:0.0.43-bionic");
private final ImageReference name;
private final Function<Owner, TarArchive> applicationContent;
private final ImageReference builder;
private final Map<String, String> env;
private final boolean cleanCache;
private final boolean versboseLogging;
BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent) {
Assert.notNull(name, "Name must not be null");
Assert.notNull(applicationContent, "ApplicationContent must not be null");
this.name = name.inTaggedForm();
this.applicationContent = applicationContent;
this.builder = DEFAULT_BUILDER;
this.env = Collections.emptyMap();
this.cleanCache = false;
this.versboseLogging = false;
}
BuildRequest(ImageReference name, Function<Owner, TarArchive> applicationContent, ImageReference builder,
Map<String, String> env, boolean cleanCache, boolean versboseLogging) {
this.name = name;
this.applicationContent = applicationContent;
this.builder = builder;
this.env = env;
this.cleanCache = cleanCache;
this.versboseLogging = versboseLogging;
}
/**
* Return a new {@link BuildRequest} with an updated builder.
* @param builder the new builder to use
* @return an updated build request
*/
public BuildRequest withBuilder(ImageReference builder) {
Assert.notNull(builder, "Builder must not be null");
return new BuildRequest(this.name, this.applicationContent, builder.inTaggedForm(), this.env, this.cleanCache,
this.versboseLogging);
}
/**
* Return a new {@link BuildRequest} with an additional env variable.
* @param name the variable name
* @param value the variable value
* @return an updated build request
*/
public BuildRequest withEnv(String name, String value) {
Assert.hasText(name, "Name must not be empty");
Assert.hasText(value, "Value must not be empty");
Map<String, String> env = new LinkedHashMap<String, String>(this.env);
env.put(name, value);
return new BuildRequest(this.name, this.applicationContent, this.builder, Collections.unmodifiableMap(env),
this.cleanCache, this.versboseLogging);
}
/**
* Return a new {@link BuildRequest} with an 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<String, String>(this.env);
updatedEnv.putAll(env);
return new BuildRequest(this.name, this.applicationContent, this.builder,
Collections.unmodifiableMap(updatedEnv), this.cleanCache, this.versboseLogging);
}
/**
* Return a new {@link BuildRequest} with an specific clean cache settings.
* @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.env, cleanCache,
this.versboseLogging);
}
/**
* Return a new {@link BuildRequest} with an specific verbose logging settings.
* @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.env, this.cleanCache,
verboseLogging);
}
/**
* Return the name of the image that should be created.
* @return the name of the image
*/
public ImageReference getName() {
return this.name;
}
/**
* Return a {@link TarArchive} containing the application content that the buildpack
* should package. This is typically the contents of the Jar.
* @param owner the owner of the tar entries
* @return the application content
* @see TarArchive#fromZip(File, Owner)
*/
public TarArchive getApplicationContent(Owner owner) {
return this.applicationContent.apply(owner);
}
/**
* Return the builder that should be used.
* @return the builder to use
*/
public ImageReference getBuilder() {
return this.builder;
}
/**
* Return any env variable that should be passed to the builder.
* @return the builder env
*/
public Map<String, String> getEnv() {
return this.env;
}
/**
* Return if caches should be cleaned before packaging.
* @return if caches should be cleaned
*/
public boolean isCleanCache() {
return this.cleanCache;
}
/**
* Return if verbose logging output should be used.
* @return if verbose logging should be used
*/
public boolean isVerboseLogging() {
return this.versboseLogging;
}
/**
* Factory method to create a new {@link BuildRequest} from a JAR file.
* @param jarFile the source jar file
* @return a new build request instance
*/
public static BuildRequest forJarFile(File jarFile) {
assertJarFile(jarFile);
return forJarFile(ImageReference.forJarFile(jarFile).inTaggedForm(), jarFile);
}
/**
* Factory method to create a new {@link BuildRequest} from a JAR file.
* @param name the name of the image that should be created
* @param jarFile the source jar file
* @return a new build request instance
*/
public static BuildRequest forJarFile(ImageReference name, File jarFile) {
assertJarFile(jarFile);
return new BuildRequest(name, (owner) -> TarArchive.fromZip(jarFile, owner));
}
/**
* Factory method to create a new {@link BuildRequest} with specific content.
* @param name the name of the image that should be created
* @param applicationContent function to provide the application content
* @return a new build request instance
*/
public static BuildRequest of(ImageReference name, Function<Owner, TarArchive> applicationContent) {
return new BuildRequest(name, applicationContent);
}
private static void assertJarFile(File jarFile) {
Assert.notNull(jarFile, "JarFile must not be null");
Assert.isTrue(jarFile.exists(), "JarFile must exist");
Assert.isTrue(jarFile.isFile(), "JarFile must be a file");
}
}

@ -0,0 +1,115 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.IOException;
import java.util.function.Consumer;
import org.springframework.boot.cloudnativebuildpack.build.BuilderMetadata.Stack;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi;
import org.springframework.boot.cloudnativebuildpack.docker.DockerException;
import org.springframework.boot.cloudnativebuildpack.docker.TotalProgressEvent;
import org.springframework.boot.cloudnativebuildpack.docker.TotalProgressPullListener;
import org.springframework.boot.cloudnativebuildpack.docker.UpdateListener;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Central API for running buildpack operations.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class Builder {
private final BuildLog log;
private final DockerApi docker;
public Builder() {
this(BuildLog.toSystemOut());
}
public Builder(BuildLog log) {
this(log, new DockerApi());
}
Builder(BuildLog log, DockerApi docker) {
Assert.notNull(log, "Log must not be null");
this.log = log;
this.docker = docker;
}
public void build(BuildRequest request) throws DockerException, IOException {
Assert.notNull(request, "Request must not be null");
this.log.start(request);
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);
EphemeralBuilder builder = new EphemeralBuilder(buildOwner, builderImage, builderMetadata, request.getEnv());
this.docker.image().load(builder.getArchive(), UpdateListener.none());
try {
executeLifecycle(request, runImageReference, builder);
}
finally {
this.docker.image().remove(builder.getName(), true);
}
}
private Image pullBuilder(BuildRequest request) throws IOException {
ImageReference builderImageReference = request.getBuilder();
Consumer<TotalProgressEvent> progressConsumer = this.log.pullingBuilder(request, builderImageReference);
TotalProgressPullListener listener = new TotalProgressPullListener(progressConsumer);
Image builderImage = this.docker.image().pull(builderImageReference, listener);
this.log.pulledBulder(request, builderImage);
return builderImage;
}
private ImageReference getRunImageReference(Stack stack) {
String name = stack.getRunImage().getImage();
Assert.state(StringUtils.hasText(name), "Run image must be specified");
return ImageReference.of(name);
}
private Image pullRunImage(BuildRequest request, ImageReference name) throws IOException {
Consumer<TotalProgressEvent> progressConsumer = this.log.pullingRunImage(request, name);
TotalProgressPullListener listener = new TotalProgressPullListener(progressConsumer);
Image image = this.docker.image().pull(name, 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 executeLifecycle(BuildRequest request, ImageReference runImageReference, EphemeralBuilder builder)
throws IOException {
try (Lifecycle lifecycle = new Lifecycle(this.log, this.docker, request, runImageReference, builder)) {
lifecycle.execute();
}
}
}

@ -0,0 +1,263 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.util.Map;
import java.util.function.Consumer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageConfig;
import org.springframework.boot.cloudnativebuildpack.json.MappedObject;
import org.springframework.boot.cloudnativebuildpack.json.SharedObjectMapper;
import org.springframework.util.Assert;
/**
* Builder metadata information.
*
* @author Phillip Webb
*/
class BuilderMetadata extends MappedObject {
private static final String LABEL_NAME = "io.buildpacks.builder.metadata";
private static final String[] EMPTY_MIRRORS = {};
private final Stack stack;
private final Lifecycle lifecycle;
private final CreatedBy createdBy;
BuilderMetadata(JsonNode node) {
super(node, MethodHandles.lookup());
this.stack = valueAt("/stack", Stack.class);
this.lifecycle = valueAt("/lifecycle", Lifecycle.class);
this.createdBy = valueAt("/createdBy", CreatedBy.class);
}
/**
* Return stack metadata.
* @return the stack metadata
*/
Stack getStack() {
return this.stack;
}
/**
* Return lifecycle metadata.
* @return the lifecycle metadata
*/
Lifecycle getLifecycle() {
return this.lifecycle;
}
/**
* Return information about who created the builder.
* @return the created by metadata
*/
CreatedBy getCreatedBy() {
return this.createdBy;
}
/**
* Create an updated copy of this metadata.
* @param update consumer to apply updates
* @return an updated metadata instance
*/
BuilderMetadata copy(Consumer<Update> update) {
return new Update(this).run(update);
}
/**
* Attach this metadata to the given update callback.
* @param update the update used to attach the metadata
*/
void attachTo(ImageConfig.Update update) {
try {
String json = SharedObjectMapper.get().writeValueAsString(getNode());
update.withLabel(LABEL_NAME, json);
}
catch (JsonProcessingException ex) {
throw new IllegalStateException(ex);
}
}
/**
* Factory method to extract {@link BuilderMetadata} from an image.
* @param image the source image
* @return the builder metadata
* @throws IOException on IO error
*/
static BuilderMetadata fromImage(Image image) throws IOException {
Assert.notNull(image, "Image must not be null");
return fromImageConfig(image.getConfig());
}
/**
* Factory method to extract {@link BuilderMetadata} from image config.
* @param imageConfig the image config
* @return the builder metadata
* @throws IOException on IO error
*/
static BuilderMetadata fromImageConfig(ImageConfig imageConfig) throws IOException {
Assert.notNull(imageConfig, "ImageConfig must not be null");
Map<String, String> labels = imageConfig.getLabels();
String json = (labels != null) ? labels.get(LABEL_NAME) : null;
Assert.notNull(json, "No '" + LABEL_NAME + "' label found in image config");
return fromJson(json);
}
/**
* Factory method create {@link BuilderMetadata} from some JSON.
* @param json the source JSON
* @return the builder metadata
* @throws IOException on IO error
*/
static BuilderMetadata fromJson(String json) throws IOException {
return new BuilderMetadata(SharedObjectMapper.get().readTree(json));
}
/**
* Stack metadata.
*/
interface Stack {
/**
* Return run image metadata.
* @return the run image metadata
*/
RunImage getRunImage();
/**
* Run image metadata.
*/
interface RunImage {
/**
* Return the builder image reference.
* @return the image reference
*/
String getImage();
/**
* Return stack mirrors.
* @return the stack mirrors
*/
default String[] getMirrors() {
return EMPTY_MIRRORS;
}
}
}
/**
* Lifecycle metadata.
*/
interface Lifecycle {
/**
* Return the lifecycle version.
* @return the lifecycle version
*/
String getVersion();
/**
* Return the API versions.
* @return the API versions
*/
Api getApi();
/**
* API versions.
*/
interface Api {
/**
* Return the buildpack API version.
* @return the buildpack version
*/
String getBuildpack();
/**
* Return the platform API version.
* @return the platform version
*/
String getPlatform();
}
}
/**
* Created-by metadata.
*/
interface CreatedBy {
/**
* Return the name of the creator.
* @return the creator name
*/
String getName();
/**
* Return the version of the creator.
* @return the creator version
*/
String getVersion();
}
/**
* Update class used to change data when creating a copy.
*/
static final class Update {
private ObjectNode copy;
private Update(BuilderMetadata source) {
this.copy = source.getNode().deepCopy();
}
private BuilderMetadata run(Consumer<Update> update) {
update.accept(this);
return new BuilderMetadata(this.copy);
}
/**
* Update the builder meta-data with a specific created by section.
* @param name the name of the creator
* @param version the version of the creator
*/
void withCreatedBy(String name, String version) {
ObjectNode createdBy = (ObjectNode) this.copy.at("/createdBy");
if (createdBy == null) {
createdBy = this.copy.putObject("createdBy");
}
createdBy.put("name", name);
createdBy.put("version", version);
}
}
}

@ -0,0 +1,160 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.Map;
import org.springframework.boot.cloudnativebuildpack.build.BuilderMetadata.Stack.RunImage;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageArchive;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.cloudnativebuildpack.docker.type.Layer;
import org.springframework.boot.cloudnativebuildpack.io.Content;
import org.springframework.boot.cloudnativebuildpack.io.Owner;
import org.springframework.boot.cloudnativebuildpack.toml.Toml;
/**
* An short lived builder that is created for each {@link Lifecycle} run.
*
* @author Phillip Webb
*/
class EphemeralBuilder {
private final BuildOwner buildOwner;
private final BuilderMetadata builderMetadata;
private final ImageArchive archive;
/**
* Create a new {@link EphemeralBuilder} instance.
* @param buildOwner the build owner
* @param builderImage the image
* @param builderMetadata the builder metadata
* @param env the builder env
* @throws IOException on IO error
*/
EphemeralBuilder(BuildOwner buildOwner, Image builderImage, BuilderMetadata builderMetadata,
Map<String, String> env) throws IOException {
this(Clock.systemUTC(), buildOwner, builderImage, builderMetadata, env);
}
/**
* Create a new {@link EphemeralBuilder} instance with a specific clock.
* @param clock the clock used for the current time
* @param buildOwner the build owner
* @param builderImage the image
* @param builderMetadata the builder metadata
* @param env the builder env
* @throws IOException on IO error
*/
EphemeralBuilder(Clock clock, BuildOwner buildOwner, Image builderImage, BuilderMetadata builderMetadata,
Map<String, String> env) throws IOException {
ImageReference name = ImageReference.random("pack.local/builder/").inTaggedForm();
this.buildOwner = buildOwner;
this.builderMetadata = builderMetadata.copy(this::updateMetadata);
this.archive = ImageArchive.from(builderImage, (update) -> {
update.withUpdatedConfig(this.builderMetadata::attachTo);
update.withTag(name);
update.withCreateDate(Instant.now(clock));
update.withNewLayer(getDefaultDirsLayer(buildOwner));
update.withNewLayer(getStackLayer(builderMetadata));
if (env != null && !env.isEmpty()) {
update.withNewLayer(getEnvLayer(env));
}
});
}
private void updateMetadata(BuilderMetadata.Update update) {
update.withCreatedBy("Spring Boot", "dev");
}
private Layer getDefaultDirsLayer(Owner buildOwner) throws IOException {
return Layer.of((layout) -> {
layout.folder("/workspace", buildOwner);
layout.folder("/layers", buildOwner);
layout.folder("/cnb", Owner.ROOT);
layout.folder("/cnb/buildpacks", Owner.ROOT);
layout.folder("/platform", Owner.ROOT);
layout.folder("/platform/env", Owner.ROOT);
});
}
private Layer getStackLayer(BuilderMetadata builderMetadata) throws IOException {
Toml toml = getRunImageToml(builderMetadata.getStack().getRunImage());
return Layer.of((layout) -> layout.file("/cnb/stack.toml", Owner.ROOT, Content.of(toml.toString())));
}
private Toml getRunImageToml(RunImage runImage) {
Toml toml = new Toml();
toml.table("run-image");
toml.string("image", runImage.getImage());
toml.array("mirrors", runImage.getMirrors());
return toml;
}
private Layer getEnvLayer(Map<String, String> env) throws IOException {
return Layer.of((layout) -> {
for (Map.Entry<String, String> entry : env.entrySet()) {
String name = "/platform/env/" + entry.getKey();
Content content = Content.of(entry.getValue());
layout.file(name, Owner.ROOT, content);
}
});
}
/**
* Return the name of this archive as tagged in Docker.
* @return the ephemeral builder name
*/
ImageReference getName() {
return this.archive.getTag();
}
/**
* Return the build owner that should be used for written content.
* @return the builder owner
*/
Owner getBuildOwner() {
return this.buildOwner;
}
/**
* Return the builder meta-data that was used to create this ephemeral builder.
* @return the builder meta-data
*/
BuilderMetadata getBuilderMetadata() {
return this.builderMetadata;
}
/**
* Return the contents of ephemeral builder for passing to Docker.
* @return the ephemeral builder archive
*/
ImageArchive getArchive() {
return this.archive;
}
@Override
public String toString() {
return this.archive.getTag().toString();
}
}

@ -0,0 +1,300 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.Closeable;
import java.io.IOException;
import java.util.function.Consumer;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi;
import org.springframework.boot.cloudnativebuildpack.docker.LogUpdateEvent;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerConfig;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerContent;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerReference;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.cloudnativebuildpack.docker.type.VolumeName;
import org.springframework.boot.cloudnativebuildpack.io.TarArchive;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* A buildpack lifecycle used to run the build {@link Phase phases} needed to package an
* application.
*
* @author Phillip Webb
*/
class Lifecycle implements Closeable {
private static final LifecycleVersion LOGGING_SUPPORTED_VERSION = LifecycleVersion.parse("0.0.5");
private final BuildLog log;
private final DockerApi docker;
private final BuildRequest request;
private final ImageReference runImageReference;
private final EphemeralBuilder builder;
private final LifecycleVersion version;
private final VolumeName layersVolume;
private final VolumeName applicationVolume;
private final VolumeName buildCacheVolume;
private final VolumeName launchCacheVolume;
private boolean executed;
private boolean applicationVolumePopulated;
/**
* Create a new {@link Lifecycle} instance.
* @param log build output log
* @param docker the Docker API
* @param request the request to process
* @param runImageReferece 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 runImageReferece,
EphemeralBuilder builder) {
checkPlatformVersion(builder);
this.log = log;
this.docker = docker;
this.request = request;
this.runImageReference = runImageReferece;
this.builder = builder;
this.version = LifecycleVersion.parse(builder.getBuilderMetadata().getLifecycle().getVersion());
this.layersVolume = createRandomVolumeName("pack-layers-");
this.applicationVolume = createRandomVolumeName("pack-app-");
this.buildCacheVolume = createCacheVolumeName(request, ".build");
this.launchCacheVolume = createCacheVolumeName(request, ".launch");
}
protected VolumeName createRandomVolumeName(String prefix) {
return VolumeName.random(prefix);
}
private VolumeName createCacheVolumeName(BuildRequest request, String suffix) {
return VolumeName.basedOn(request.getName(), ImageReference::toLegacyString, "pack-cache-", suffix, 6);
}
private void checkPlatformVersion(EphemeralBuilder ephemeralBuilder) {
String platformVersion = ephemeralBuilder.getBuilderMetadata().getLifecycle().getApi().getPlatform();
if (StringUtils.hasText(platformVersion)) {
ApiVersion.PLATFORM.assertSupports(ApiVersion.parse(platformVersion));
}
}
/**
* Execute this lifecycle by running each phase in turn.
* @throws IOException on IO error
*/
void execute() throws IOException {
Assert.state(!this.executed, "Lifecycle has already been executed");
this.executed = true;
this.log.executingLifecycle(this.request, this.version, this.buildCacheVolume);
if (this.request.isCleanCache()) {
deleteVolume(this.buildCacheVolume);
}
run(detectPhase());
run(restorePhase());
run(analyzePhase());
run(buildPhase());
run(exportPhase());
run(cachePhase());
this.log.executedLifecycle(this.request);
}
private Phase detectPhase() {
Phase phase = createPhase("detector");
phase.withArgs("-app", Folder.APPLICATION);
phase.withArgs("-platform", Folder.PLATFORM);
phase.withLogLevelArg();
return phase;
}
private Phase restorePhase() {
Phase phase = createPhase("restorer");
phase.withDaemonAccess();
phase.withArgs("-path", Folder.CACHE);
phase.withArgs("-layers", Folder.LAYERS);
phase.withLogLevelArg();
phase.withBinds(this.buildCacheVolume, Folder.CACHE);
return phase;
}
private Phase analyzePhase() {
Phase phase = createPhase("analyzer");
phase.withDaemonAccess();
phase.withLogLevelArg();
if (this.request.isCleanCache()) {
phase.withArgs("-skip-layers");
}
phase.withArgs("-daemon");
phase.withArgs("-layers", Folder.LAYERS);
phase.withArgs(this.request.getName());
return phase;
}
private Phase buildPhase() {
Phase phase = createPhase("builder");
phase.withArgs("-layers", Folder.LAYERS);
phase.withArgs("-app", Folder.APPLICATION);
phase.withArgs("-platform", Folder.PLATFORM);
return phase;
}
private Phase exportPhase() {
Phase phase = createPhase("exporter");
phase.withDaemonAccess();
phase.withLogLevelArg();
phase.withArgs("-image", this.runImageReference);
phase.withArgs("-layers", Folder.LAYERS);
phase.withArgs("-app", Folder.APPLICATION);
phase.withArgs("-daemon");
phase.withArgs("-launch-cache", Folder.LAUNCH_CACHE);
phase.withArgs(this.request.getName());
phase.withBinds(this.launchCacheVolume, Folder.LAUNCH_CACHE);
return phase;
}
private Phase cachePhase() {
Phase phase = createPhase("cacher");
phase.withDaemonAccess();
phase.withArgs("-path", Folder.CACHE);
phase.withArgs("-layers", Folder.LAYERS);
phase.withLogLevelArg();
phase.withBinds(this.buildCacheVolume, Folder.CACHE);
return phase;
}
private Phase createPhase(String name) {
boolean verboseLogging = this.request.isVerboseLogging()
&& this.version.isEqualOrGreaterThan(LOGGING_SUPPORTED_VERSION);
Phase phase = new Phase(name, verboseLogging);
phase.withBinds(this.layersVolume, Folder.LAYERS);
phase.withBinds(this.applicationVolume, Folder.APPLICATION);
return phase;
}
private void run(Phase phase) throws IOException {
Consumer<LogUpdateEvent> logConsumer = this.log.runningPhase(this.request, phase.getName());
ContainerConfig containerConfig = ContainerConfig.of(this.builder.getName(), phase::apply);
ContainerReference reference = createContainer(containerConfig);
try {
this.docker.container().start(reference);
this.docker.container().logs(reference, logConsumer::accept);
}
finally {
this.docker.container().remove(reference, true);
}
}
private ContainerReference createContainer(ContainerConfig config) throws IOException {
if (this.applicationVolumePopulated) {
return this.docker.container().create(config);
}
try {
TarArchive applicationContent = this.request.getApplicationContent(this.builder.getBuildOwner());
return this.docker.container().create(config, ContainerContent.of(applicationContent, Folder.APPLICATION));
}
finally {
this.applicationVolumePopulated = true;
}
}
@Override
public void close() throws IOException {
deleteVolume(this.layersVolume);
deleteVolume(this.applicationVolume);
}
private void deleteVolume(VolumeName name) throws IOException {
this.docker.volume().delete(name, true);
}
/**
* Common folders used by the various phases.
*/
private static class Folder {
/**
* The folder used by buildpacks to write their layer contributions. A new layer
* folder is created for each lifecycle execution.
* <p>
* Maps to the {@code <layers...>} concept in the
* <a href="https://github.com/buildpacks/spec/blob/master/buildpack.md">buildpack
* specification</a> and the {@code -layers} argument from the reference lifecycle
* implementation.
*/
static final String LAYERS = "/layers";
/**
* The folder containing the original contributed application. A new application
* folder is created for each lifecycle execution.
* <p>
* Maps to the {@code <app...>} concept in the
* <a href="https://github.com/buildpacks/spec/blob/master/buildpack.md">buildpack
* specification</a> and the {@code -app} argument from the reference lifecycle
* implementation. The reference lifecycle follows the Kubernetes/Docker
* convention of using {@code '/workspace'}.
* <p>
* Note that application content is uploaded to the container with the first phase
* that runs and saved in a volume that is passed to supsequent phases. The folder
* is mutable and buildpacks may modify the content.
*/
static final String APPLICATION = "/workspace";
/**
* The folder used by buildpacks to obtain environment variables and platform
* specific concerns. The platform folder is read-only and is created/populated by
* the {@link EphemeralBuilder}.
* <p>
* Maps to the {@code <platform>/env} and {@code <platform>/#} concepts in the
* <a href="https://github.com/buildpacks/spec/blob/master/buildpack.md">buildpack
* specification</a> and the {@code -platform} argument from the reference
* lifecycle implementation.
*/
static final String PLATFORM = "/platform";
/**
* The folder used by buildpacks for caching. The volume name is based on the
* image {@link BuildRequest#getName() name} being built, and is persistent across
* invocations even if the application content has changed.
* <p>
* Maps to the {@code -path} argument from the reference lifecycle implementation
* cache and restore phases
*/
static final String CACHE = "/cache";
/**
* The folder used by buildpacks for launch related caching. The volume name is
* based on the image {@link BuildRequest#getName() name} being built, and is
* persistent across invocations even if the application content has changed.
* <p>
* Maps to the {@code -launch-cache} argument from the reference lifecycle
* implementation export phase
*/
static final String LAUNCH_CACHE = "/launch-cache";
}
}

@ -0,0 +1,140 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.util.Comparator;
import org.springframework.util.Assert;
/**
* A lifecycle version number comprised of a major, minor and patch value.
*
* @author Phillip Webb
*/
class LifecycleVersion implements Comparable<LifecycleVersion> {
private static final Comparator<LifecycleVersion> COMPARATOR = Comparator.comparingInt(LifecycleVersion::getMajor)
.thenComparingInt(LifecycleVersion::getMinor).thenComparing(LifecycleVersion::getPatch);
private final int major;
private final int minor;
private final int patch;
LifecycleVersion(int major, int minor, int patch) {
this.major = major;
this.minor = minor;
this.patch = patch;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
LifecycleVersion other = (LifecycleVersion) obj;
boolean result = true;
result = result && this.major == other.major;
result = result && this.minor == other.minor;
result = result && this.patch == other.patch;
return result;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + this.major;
result = prime * result + this.minor;
result = prime * result + this.patch;
return result;
}
@Override
public String toString() {
return "v" + this.major + "." + this.minor + "." + this.patch;
}
/**
* Return if this version is greater than or equal to the specified version.
* @param other the version to compare
* @return {@code true} if this version is greater than or equal to the specified
* version
*/
boolean isEqualOrGreaterThan(LifecycleVersion other) {
return this.compareTo(other) >= 0;
}
@Override
public int compareTo(LifecycleVersion other) {
return COMPARATOR.compare(this, other);
}
/**
* Return the major version number.
* @return the major version
*/
int getMajor() {
return this.major;
}
/**
* Return the minor version number.
* @return the minor version
*/
int getMinor() {
return this.minor;
}
/**
* Return the patch version number.
* @return the patch version
*/
int getPatch() {
return this.patch;
}
/**
* Factory method to parse a string into a {@link LifecycleVersion} instance.
* @param value the value to parse.
* @return the corresponding {@link LifecycleVersion}
* @throws IllegalArgumentException if the value could not be parsed
*/
static LifecycleVersion parse(String value) {
Assert.hasText(value, "Value must not be empty");
if (value.startsWith("v") || value.startsWith("V")) {
value = value.substring(1);
}
String[] components = value.split("\\.");
Assert.isTrue(components.length <= 3, "Malformed version number '" + value + "'");
int[] versions = new int[3];
for (int i = 0; i < components.length; i++) {
try {
versions[i] = Integer.parseInt(components[i]);
}
catch (NumberFormatException ex) {
throw new IllegalArgumentException("Malformed version number '" + value + "'", ex);
}
}
return new LifecycleVersion(versions[0], versions[1], versions[2]);
}
}

@ -0,0 +1,120 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerConfig;
import org.springframework.boot.cloudnativebuildpack.docker.type.VolumeName;
import org.springframework.util.StringUtils;
/**
* An individual build phase executed as part of a {@link Lifecycle} run.
*
* @author Phillip Webb
*/
class Phase {
private static final String DOMAIN_SOCKET_PATH = "/var/run/docker.sock";
private final String name;
private final boolean verboseLogging;
private boolean daemonAccess = false;
private final List<String> args = new ArrayList<>();
private final Map<VolumeName, String> binds = new LinkedHashMap<>();
/**
* Create a new {@link Phase} instance.
* @param name the name of the phase
* @param verboseLogging if verbose logging is requested
*/
Phase(String name, boolean verboseLogging) {
this.name = name;
this.verboseLogging = verboseLogging;
}
/**
* Update this phase with Docker daemon access.
*/
void withDaemonAccess() {
this.daemonAccess = true;
}
/**
* Update this phase with a debug log level arguments if verbose logging has been
* requested.
*/
void withLogLevelArg() {
if (this.verboseLogging) {
this.args.add("-log-level");
this.args.add("debug");
}
}
/**
* Update this phase with additional run arguments.
* @param args the arguments to add
*/
void withArgs(Object... args) {
Arrays.stream(args).map(Object::toString).forEach(this.args::add);
}
/**
* Update this phase with an addition volume binding.
* @param source the source volume
* @param dest the destination location
*/
void withBinds(VolumeName source, String dest) {
this.binds.put(source, dest);
}
/**
* Return the name of the phase.
* @return the phase name
*/
String getName() {
return this.name;
}
@Override
public String toString() {
return this.name;
}
/**
* Apply this phase settings to a {@link ContainerConfig} update.
* @param update the update to apply the phase to
*/
void apply(ContainerConfig.Update update) {
if (this.daemonAccess) {
update.withUser("root");
update.withBind(DOMAIN_SOCKET_PATH, DOMAIN_SOCKET_PATH);
}
update.withCommand("/lifecycle/" + this.name, StringUtils.toStringArray(this.args));
update.withLabel("author", "spring-boot");
this.binds.forEach((source, dest) -> update.withBind(source, dest));
}
}

@ -0,0 +1,49 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.PrintStream;
import java.util.function.Consumer;
import org.springframework.boot.cloudnativebuildpack.docker.TotalProgressBar;
import org.springframework.boot.cloudnativebuildpack.docker.TotalProgressEvent;
/**
* {@link BuildLog} implementation that prints output to a {@link PrintStream}.
*
* @author Phillip Webb
* @see BuildLog#to(PrintStream)
*/
class PrintStreamBuildLog extends AbstractBuildLog {
private final PrintStream out;
PrintStreamBuildLog(PrintStream out) {
this.out = out;
}
@Override
protected void log(String message) {
this.out.println(message);
}
@Override
protected Consumer<TotalProgressEvent> getProgressConsumer(String prefix) {
return new TotalProgressBar(prefix, '.', false, this.out);
}
}

@ -0,0 +1,94 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.util.Map;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageConfig;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* A Stack ID.
*
* @author Phillip Webb
*/
class StackId {
private static final String LABEL_NAME = "io.buildpacks.stack.id";
private final String value;
StackId(String value) {
this.value = value;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
return this.value.equals(((StackId) obj).value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Override
public String toString() {
return this.value;
}
/**
* Factory method to create a {@link StackId} from an {@link Image}.
* @param image the source image
* @return the extracted stack ID
*/
static StackId fromImage(Image image) {
Assert.notNull(image, "Image must not be null");
return fromImageConfig(image.getConfig());
}
/**
* Factory method to create a {@link StackId} from an {@link ImageConfig}.
* @param imageConfig the source image config
* @return the extracted stack ID
*/
private static StackId fromImageConfig(ImageConfig imageConfig) {
Map<String, String> labels = imageConfig.getLabels();
String value = (labels != null) ? labels.get(LABEL_NAME) : null;
Assert.state(StringUtils.hasText(value), "Missing '" + LABEL_NAME + "' stack label");
return new StackId(value);
}
/**
* Factory method to create a {@link StackId} with a given value.
* @param value the stack ID value
* @return a new stack ID instance
*/
static StackId of(String value) {
Assert.hasText(value, "Value must not be empty");
return new StackId(value);
}
}

@ -0,0 +1,20 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Central API for performing a buildpack build.
*/
package org.springframework.boot.cloudnativebuildpack.build;

@ -0,0 +1,338 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.apache.http.client.utils.URIBuilder;
import org.springframework.boot.cloudnativebuildpack.docker.Http.Response;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerConfig;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerContent;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerReference;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageArchive;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.cloudnativebuildpack.docker.type.VolumeName;
import org.springframework.boot.cloudnativebuildpack.json.JsonStream;
import org.springframework.boot.cloudnativebuildpack.json.SharedObjectMapper;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Provides access to the limited set of Docker APIs needed by pack.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class DockerApi {
private static final List<String> FORCE_PARAMS = Collections.unmodifiableList(Arrays.asList("force", "1"));
private final Http http;
private final JsonStream jsonStream;
private final ImageApi image;
private final ContainerApi container;
private final VolumeApi volume;
/**
* Create a new {@link DockerApi} instance.
*/
public DockerApi() {
this(new HttpClientHttp());
}
/**
* Create a new {@link DockerApi} instance backed by a specific {@link HttpClientHttp}
* implementation.
* @param http the http implementation
*/
DockerApi(Http http) {
this.http = http;
this.jsonStream = new JsonStream(SharedObjectMapper.get());
this.image = new ImageApi();
this.container = new ContainerApi();
this.volume = new VolumeApi();
}
private Http http() {
return this.http;
}
private JsonStream jsonStream() {
return this.jsonStream;
}
private URI buildUrl(String path, Collection<String> params) {
return buildUrl(path, StringUtils.toStringArray(params));
}
private URI buildUrl(String path, String... params) {
try {
URIBuilder builder = new URIBuilder("docker://localhost/v1.40" + path);
int param = 0;
while (param < params.length) {
builder.addParameter(params[param++], params[param++]);
}
return builder.build();
}
catch (URISyntaxException ex) {
throw new IllegalStateException(ex);
}
}
/**
* Return the Docker API for image operations.
* @return the image API
*/
public ImageApi image() {
return this.image;
}
/**
* Return the Docker API for container operations.
* @return the container API
*/
public ContainerApi container() {
return this.container;
}
public VolumeApi volume() {
return this.volume;
}
/**
* Docker API for image operations.
*/
public class ImageApi {
ImageApi() {
}
/**
* Pull an image from a registry.
* @param reference the image reference to pull
* @param listener a pull listener to receive update events
* @return the {@link ImageApi pulled image} instance
* @throws IOException on IO error
*/
public Image pull(ImageReference reference, UpdateListener<PullImageUpdateEvent> listener) throws IOException {
Assert.notNull(reference, "Reference must not be null");
Assert.notNull(listener, "Listener must not be null");
URI createUri = buildUrl("/images/create", "fromImage", reference.toString());
DigestCaptureUpdateListener digestCapture = new DigestCaptureUpdateListener();
listener.onStart();
try {
try (Response response = http().post(createUri)) {
jsonStream().get(response.getContent(), PullImageUpdateEvent.class, (event) -> {
digestCapture.onUpdate(event);
listener.onUpdate(event);
});
}
URI imageUri = buildUrl("/images/" + reference.withDigest(digestCapture.getCapturedDigest()) + "/json");
try (Response response = http().get(imageUri)) {
return Image.of(response.getContent());
}
}
finally {
listener.onFinish();
}
}
/**
* Load an {@link ImageArchive} into Docker.
* @param archive the archive to load
* @param listener a pull listener to receive update events
* @throws IOException on IO error
*/
public void load(ImageArchive archive, UpdateListener<LoadImageUpdateEvent> listener) throws IOException {
Assert.notNull(archive, "Archive must not be null");
Assert.notNull(listener, "Listener must not be null");
URI loadUri = buildUrl("/images/load");
listener.onStart();
try {
try (Response response = http().post(loadUri, "application/x-tar", archive::writeTo)) {
jsonStream().get(response.getContent(), LoadImageUpdateEvent.class, listener::onUpdate);
}
}
finally {
listener.onFinish();
}
}
/**
* Remove a specific image.
* @param reference the reference the remove
* @param force if removal should be forced
* @throws IOException on IO error
*/
public void remove(ImageReference reference, boolean force) throws IOException {
Assert.notNull(reference, "Reference must not be null");
Collection<String> params = force ? FORCE_PARAMS : Collections.emptySet();
URI uri = buildUrl("/images/" + reference, params);
http().delete(uri);
}
}
/**
* Docker API for container operations.
*/
public class ContainerApi {
ContainerApi() {
}
/**
* Create a new container a {@link ContainerConfig}.
* @param config the container config
* @param contents additional contents to include
* @return a {@link ContainerReference} for the newly created container
* @throws IOException on IO error
*/
public ContainerReference create(ContainerConfig config, ContainerContent... contents) throws IOException {
Assert.notNull(config, "Config must not be null");
Assert.noNullElements(contents, "Contents must not contain null elements");
ContainerReference containerReference = createContainer(config);
for (ContainerContent content : contents) {
uploadContainerContent(containerReference, content);
}
return containerReference;
}
private ContainerReference createContainer(ContainerConfig config) throws IOException {
URI createUri = buildUrl("/containers/create");
try (Response response = http().post(createUri, "application/json", config::writeTo)) {
ContainerReference containerReference = ContainerReference
.of(SharedObjectMapper.get().readTree(response.getContent()).at("/Id").asText());
return containerReference;
}
}
private void uploadContainerContent(ContainerReference reference, ContainerContent content) throws IOException {
URI uri = buildUrl("/containers/" + reference + "/archive", "path", content.getDestinationPath());
http().put(uri, "application/x-tar", content.getArchive()::writeTo).close();
}
/**
* Start a specific container.
* @param reference the container reference to start
* @throws IOException on IO error
*/
public void start(ContainerReference reference) throws IOException {
Assert.notNull(reference, "Reference must not be null");
URI uri = buildUrl("/containers/" + reference + "/start");
http().post(uri);
}
/**
* Return and follow logs for a specific container.
* @param reference the container reference
* @param listener a listener to receive log update events
* @throws IOException on IO error
*/
public void logs(ContainerReference reference, UpdateListener<LogUpdateEvent> listener) throws IOException {
Assert.notNull(reference, "Reference must not be null");
Assert.notNull(listener, "Listener must not be null");
String[] params = { "stdout", "1", "stderr", "1", "follow", "1" };
URI uri = buildUrl("/containers/" + reference + "/logs", params);
listener.onStart();
try {
try (Response response = http().get(uri)) {
LogUpdateEvent.readAll(response.getContent(), listener::onUpdate);
}
}
finally {
listener.onFinish();
}
}
/**
* Remove a specific container.
* @param reference the container to remove
* @param force if removal should be forced
* @throws IOException on IO error
*/
public void remove(ContainerReference reference, boolean force) throws IOException {
Assert.notNull(reference, "Reference must not be null");
Collection<String> params = force ? FORCE_PARAMS : Collections.emptySet();
URI uri = buildUrl("/containers/" + reference, params);
http().delete(uri);
}
}
/**
* Docker API for volume operations.
*/
public class VolumeApi {
VolumeApi() {
}
/**
* Delete a volume.
* @param name the name of the volume to delete
* @param force if the deletion should be forced
* @throws IOException on IO error
*/
public void delete(VolumeName name, boolean force) throws IOException {
Assert.notNull(name, "Name must not be null");
Collection<String> params = force ? FORCE_PARAMS : Collections.emptySet();
URI uri = buildUrl("/volumes/" + name, params);
http().delete(uri);
}
}
/**
* {@link UpdateListener} used to capture the image digest.
*/
private static class DigestCaptureUpdateListener implements UpdateListener<ProgressUpdateEvent> {
private static final String PREFIX = "Digest:";
private String digest;
@Override
public void onUpdate(ProgressUpdateEvent event) {
String status = event.getStatus();
if (status != null && status.startsWith(PREFIX)) {
String digest = status.substring(PREFIX.length()).trim();
Assert.state(this.digest == null || this.digest.equals(digest), "Different digests IDs provided");
this.digest = digest;
}
}
String getCapturedDigest() {
Assert.hasText(this.digest, "No digest found");
return this.digest;
}
}
}

@ -0,0 +1,57 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import com.sun.jna.Platform;
import org.apache.http.HttpHost;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.protocol.HttpContext;
import org.springframework.boot.cloudnativebuildpack.socket.DomainSocket;
import org.springframework.boot.cloudnativebuildpack.socket.NamedPipeSocket;
/**
* {@link ConnectionSocketFactory} that connects to the Docker domain socket or named
* pipe.
*
* @author Phillip Webb
*/
class DockerConnectionSocketFactory implements ConnectionSocketFactory {
private static final String DOMAIN_SOCKET_PATH = "/var/run/docker.sock";
private static final String WINDOWS_NAMED_PIPE_PATH = "//./pipe/docker_engine";
@Override
public Socket createSocket(HttpContext context) throws IOException {
if (Platform.isWindows()) {
NamedPipeSocket.get(WINDOWS_NAMED_PIPE_PATH);
}
return DomainSocket.get(DOMAIN_SOCKET_PATH);
}
@Override
public Socket connectSocket(int connectTimeout, Socket sock, HttpHost host, InetSocketAddress remoteAddress,
InetSocketAddress localAddress, HttpContext context) throws IOException {
return sock;
}
}

@ -0,0 +1,39 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.net.InetAddress;
import java.net.UnknownHostException;
import org.apache.http.conn.DnsResolver;
/**
* {@link DnsResolver} used by the {@link DockerHttpClientConnectionManager} to ensure
* only the loopback address is used.
*
* @author Phillip Webb
*/
class DockerDnsResolver implements DnsResolver {
private static final InetAddress[] LOOPBACK = new InetAddress[] { InetAddress.getLoopbackAddress() };
@Override
public InetAddress[] resolve(String host) throws UnknownHostException {
return LOOPBACK;
}
}

@ -0,0 +1,82 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.net.URI;
import org.springframework.util.Assert;
/**
* Exception throw when the Docker API fails.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class DockerException extends RuntimeException {
private final int statusCode;
private final String reasonPhrase;
private final Errors errors;
DockerException(URI uri, int statusCode, String reasonPhrase, Errors errors) {
super(buildMessage(uri, statusCode, reasonPhrase, errors));
this.statusCode = statusCode;
this.reasonPhrase = reasonPhrase;
this.errors = errors;
}
/**
* Return the status code returned by the Docker API.
* @return the statusCode the status code
*/
public int getStatusCode() {
return this.statusCode;
}
/**
* Return the reason phrase returned by the Docker API error.
* @return the reasonPhrase
*/
public String getReasonPhrase() {
return this.reasonPhrase;
}
/**
* Return the Errors from the body of the Docker API error, or {@code null} if the
* error JSON could not be read.
* @return the errors or {@code null}
*/
public Errors getErrors() {
return this.errors;
}
private static String buildMessage(URI uri, int statusCode, String reasonPhrase, Errors errors) {
Assert.notNull(uri, "URI must not be null");
StringBuilder message = new StringBuilder(
"Docker API call to '" + uri + "' failed with status code " + statusCode);
if (reasonPhrase != null && !reasonPhrase.isEmpty()) {
message.append(" \"" + reasonPhrase + "\"");
}
if (errors != null && !errors.isEmpty()) {
message.append(" " + errors);
}
return message.toString();
}
}

@ -0,0 +1,42 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.HttpClientConnectionManager;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.impl.conn.BasicHttpClientConnectionManager;
/**
* {@link HttpClientConnectionManager} for Docker.
*
* @author Phillip Webb
*/
class DockerHttpClientConnectionManager extends BasicHttpClientConnectionManager {
DockerHttpClientConnectionManager() {
super(getRegistry(), null, null, new DockerDnsResolver());
}
private static Registry<ConnectionSocketFactory> getRegistry() {
RegistryBuilder<ConnectionSocketFactory> builder = RegistryBuilder.create();
builder.register("docker", new DockerConnectionSocketFactory());
return builder.build();
}
}

@ -0,0 +1,43 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import org.apache.http.HttpHost;
import org.apache.http.conn.SchemePortResolver;
import org.apache.http.conn.UnsupportedSchemeException;
import org.apache.http.util.Args;
/**
* {@link SchemePortResolver} for Docker.
*
* @author Phillip Webb
*/
class DockerSchemePortResolver implements SchemePortResolver {
private static int DEFAULT_DOCKER_PORT = 2376;
@Override
public int resolve(HttpHost host) throws UnsupportedSchemeException {
Args.notNull(host, "HTTP host");
String name = host.getSchemeName();
if ("docker".equals(name)) {
return DEFAULT_DOCKER_PORT;
}
throw new UnsupportedSchemeException(name + " protocol is not supported");
}
}

@ -0,0 +1,106 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.stream.Stream;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Errors returned from the Docker API.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class Errors implements Iterable<Errors.Error> {
private final List<Error> errors;
@JsonCreator
Errors(@JsonProperty("errors") List<Error> errors) {
this.errors = (errors != null) ? errors : Collections.emptyList();
}
@Override
public Iterator<Errors.Error> iterator() {
return this.errors.iterator();
}
/**
* Returns a sequential {@code Stream} of the errors.
* @return a stream of the errors
*/
public Stream<Error> stream() {
return this.errors.stream();
}
/**
* Return if the there are any contained errors.
* @return if the errors are empty
*/
public boolean isEmpty() {
return this.errors.isEmpty();
}
@Override
public String toString() {
return this.errors.toString();
}
/**
* An individual Docker error.
*/
public static class Error {
private final String code;
private final String message;
@JsonCreator
Error(String code, String message) {
this.code = code;
this.message = message;
}
/**
* Return the error code.
* @return the error code
*/
public String getCode() {
return this.code;
}
/**
* Return the error message.
* @return the error message
*/
public String getMessage() {
return this.message;
}
@Override
public String toString() {
return this.code + ": " + this.message;
}
}
}

@ -0,0 +1,92 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import org.springframework.boot.cloudnativebuildpack.io.IOConsumer;
/**
* HTTP transport used by the {@link DockerApi}.
*
* @author Phillip Webb
*/
interface Http {
/**
* Perform a HTTP GET operation.
* @param uri the destination URI
* @return the operation response
* @throws IOException on IO error
*/
Response get(URI uri) throws IOException;
/**
* Perform a HTTP POST operation.
* @param uri the destination URI
* @return the operation response
* @throws IOException on IO error
*/
Response post(URI uri) throws IOException;
/**
* Perform a HTTP POST operation.
* @param uri the destination URI
* @param contentType the content type to write
* @param writer a content writer
* @return the operation response
* @throws IOException on IO error
*/
Response post(URI uri, String contentType, IOConsumer<OutputStream> writer) throws IOException;
/**
* Perform a HTTP PUT operation.
* @param uri the destination URI
* @param contentType the content type to write
* @param writer a content writer
* @return the operation response
* @throws IOException on IO error
*/
Response put(URI uri, String contentType, IOConsumer<OutputStream> writer) throws IOException;
/**
* Perform a HTTP DELETE operation.
* @param uri the destination URI
* @return the operation response
* @throws IOException on IO error
*/
Response delete(URI uri) throws IOException;
/**
* An HTTP operation response.
*/
interface Response extends Closeable {
/**
* Return the content of the response.
* @return the reseponse content
* @throws IOException on IO error
*/
InputStream getContent() throws IOException;
}
}

@ -0,0 +1,217 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHeaders;
import org.apache.http.StatusLine;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.entity.AbstractHttpEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.springframework.boot.cloudnativebuildpack.io.Content;
import org.springframework.boot.cloudnativebuildpack.io.IOConsumer;
import org.springframework.boot.cloudnativebuildpack.json.SharedObjectMapper;
/**
* {@link Http} implementation backed by a {@link HttpClient}.
*
* @author Phillip Webb
*/
class HttpClientHttp implements Http {
private final CloseableHttpClient client;
HttpClientHttp() {
HttpClientBuilder builder = HttpClients.custom();
builder.setConnectionManager(new DockerHttpClientConnectionManager());
builder.setSchemePortResolver(new DockerSchemePortResolver());
this.client = builder.build();
}
HttpClientHttp(CloseableHttpClient client) {
this.client = client;
}
/**
* Perform a HTTP GET operation.
* @param uri the destination URI
* @return the operation response
* @throws IOException on IO error
*/
@Override
public Response get(URI uri) throws IOException {
return execute(new HttpGet(uri));
}
/**
* Perform a HTTP POST operation.
* @param uri the destination URI
* @return the operation response
* @throws IOException on IO error
*/
@Override
public Response post(URI uri) throws IOException {
return execute(new HttpPost(uri));
}
/**
* Perform a HTTP POST operation.
* @param uri the destination URI
* @param contentType the content type to write
* @param writer a content writer
* @return the operation response
* @throws IOException on IO error
*/
@Override
public Response post(URI uri, String contentType, IOConsumer<OutputStream> writer) throws IOException {
return execute(new HttpPost(uri), contentType, writer);
}
/**
* Perform a HTTP PUT operation.
* @param uri the destination URI
* @param contentType the content type to write
* @param writer a content writer
* @return the operation response
* @throws IOException on IO error
*/
@Override
public Response put(URI uri, String contentType, IOConsumer<OutputStream> writer) throws IOException {
return execute(new HttpPut(uri), contentType, writer);
}
/**
* Perform a HTTP DELETE operation.
* @param uri the destination URI
* @return the operation response
* @throws IOException on IO error
*/
@Override
public Response delete(URI uri) throws IOException {
return execute(new HttpDelete(uri));
}
private Response execute(HttpEntityEnclosingRequestBase request, String contentType,
IOConsumer<OutputStream> writer) throws IOException {
request.setHeader(HttpHeaders.CONTENT_TYPE, contentType);
request.setEntity(new WritableHttpEntity(writer));
return execute(request);
}
private Response execute(HttpUriRequest request) throws IOException {
CloseableHttpResponse response = this.client.execute(request);
StatusLine statusLine = response.getStatusLine();
int statusCode = statusLine.getStatusCode();
HttpEntity entity = response.getEntity();
if (statusCode >= 200 && statusCode < 300) {
return new HttpClientResponse(response);
}
Errors errors = null;
if (statusCode >= 400 && statusCode < 500) {
try {
errors = SharedObjectMapper.get().readValue(entity.getContent(), Errors.class);
}
catch (Exception ex) {
}
}
EntityUtils.consume(entity);
throw new DockerException(request.getURI(), statusCode, statusLine.getReasonPhrase(), errors);
}
/**
* {@link HttpEntity} to send {@link Content} content.
*
* @author Phillip Webb
*/
private class WritableHttpEntity extends AbstractHttpEntity {
private final IOConsumer<OutputStream> writer;
WritableHttpEntity(IOConsumer<OutputStream> writer) {
this.writer = writer;
}
@Override
public boolean isRepeatable() {
return false;
}
@Override
public long getContentLength() {
return -1;
}
@Override
public InputStream getContent() throws IOException, UnsupportedOperationException {
throw new UnsupportedOperationException();
}
@Override
public void writeTo(OutputStream outputStream) throws IOException {
this.writer.accept(outputStream);
}
@Override
public boolean isStreaming() {
return true;
}
}
/**
* An HTTP operation response.
*/
private static class HttpClientResponse implements Response {
private final CloseableHttpResponse response;
HttpClientResponse(CloseableHttpResponse response) {
this.response = response;
}
@Override
public InputStream getContent() throws IOException {
return this.response.getEntity().getContent();
}
@Override
public void close() throws IOException {
this.response.close();
}
}
}

@ -0,0 +1,45 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import com.fasterxml.jackson.annotation.JsonCreator;
/**
* A {@link ProgressUpdateEvent} fired as an image is loaded.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class LoadImageUpdateEvent extends ProgressUpdateEvent {
private final String stream;
@JsonCreator
public LoadImageUpdateEvent(String stream, String status, ProgressDetail progressDetail, String progress) {
super(status, progressDetail, progress);
this.stream = stream;
}
/**
* Return the stream response or {@code null} if no response is available.
* @return the stream response.
*/
public String getStream() {
return this.stream;
}
}

@ -0,0 +1,138 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.function.Consumer;
import java.util.regex.Pattern;
/**
* An update event used to provide log updates.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class LogUpdateEvent extends UpdateEvent {
private static final Pattern ANSI_PATTERN = Pattern.compile("\u001B\\[[;\\d]*m");
private static final Pattern TRAILING_NEW_LINE_PATTERN = Pattern.compile("\\n$");
private final StreamType streamType;
private final byte[] payload;
private final String string;
LogUpdateEvent(StreamType streamType, byte[] payload) {
this.streamType = streamType;
this.payload = payload;
String string = new String(payload, StandardCharsets.UTF_8);
string = ANSI_PATTERN.matcher(string).replaceAll("");
string = TRAILING_NEW_LINE_PATTERN.matcher(string).replaceAll("");
this.string = string;
}
public void print() {
switch (this.streamType) {
case STD_OUT:
System.out.println(this);
return;
case STD_ERR:
System.err.println(this);
return;
}
}
public StreamType getStreamType() {
return this.streamType;
}
public byte[] getPayload() {
return this.payload;
}
@Override
public String toString() {
return this.string;
}
static void readAll(InputStream inputStream, Consumer<LogUpdateEvent> consumer) throws IOException {
try {
LogUpdateEvent event;
while ((event = LogUpdateEvent.read(inputStream)) != null) {
consumer.accept(event);
}
}
finally {
inputStream.close();
}
}
private static LogUpdateEvent read(InputStream inputStream) throws IOException {
byte[] header = read(inputStream, 8);
if (header == null) {
return null;
}
StreamType streamType = StreamType.values()[header[0]];
long size = 0;
for (int i = 0; i < 4; i++) {
size = (size << 8) + (header[i + 4] & 0xff);
}
byte[] payload = read(inputStream, size);
return new LogUpdateEvent(streamType, payload);
}
private static byte[] read(InputStream inputStream, long size) throws IOException {
byte[] data = new byte[(int) size];
int offset = 0;
do {
int amountRead = inputStream.read(data, offset, data.length - offset);
if (amountRead == -1) {
return null;
}
offset += amountRead;
}
while (offset < data.length);
return data;
}
/**
* Stream types supported by the event.
*/
public enum StreamType {
/**
* Input from {@code stdin}.
*/
STD_IN,
/**
* Output to {@code stdout}.
*/
STD_OUT,
/**
* Output to {@code stderr}.
*/
STD_ERR
}
}

@ -0,0 +1,102 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import com.fasterxml.jackson.annotation.JsonCreator;
/**
* An {@link UpdateEvent} that includes progress information.
*
* @author Phillip Webb
* @since 2.3.0
*/
public abstract class ProgressUpdateEvent extends UpdateEvent {
private final String status;
private final ProgressDetail progressDetail;
private final String progress;
protected ProgressUpdateEvent(String status, ProgressDetail progressDetail, String progress) {
this.status = status;
this.progressDetail = (ProgressDetail.isEmpty(progressDetail)) ? null : progressDetail;
this.progress = progress;
}
/**
* Return the status for the update. For example, "Extracting" or "Downloading".
* @return the status of the update.
*/
public String getStatus() {
return this.status;
}
/**
* Return progress details if available.
* @return progress details or {@code null}
*/
public ProgressDetail getProgressDetail() {
return this.progressDetail;
}
/**
* Return a text based progress bar if progress information is available.
* @return the progress bar or {@code null}
*/
public String getProgress() {
return this.progress;
}
/**
* Provide details about the progress of a task.
*/
public static class ProgressDetail {
private final Integer current;
private final Integer total;
@JsonCreator
public ProgressDetail(Integer current, Integer total) {
this.current = current;
this.total = total;
}
/**
* Return the current progress value.
* @return the current progress
*/
public int getCurrent() {
return this.current;
}
/**
* Return the total progress possible value.
* @return the total progress possible
*/
public int getTotal() {
return this.total;
}
public static boolean isEmpty(ProgressDetail progressDetail) {
return progressDetail == null || progressDetail.current == null || progressDetail.total == null;
}
}
}

@ -0,0 +1,45 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import com.fasterxml.jackson.annotation.JsonCreator;
/**
* A {@link ProgressUpdateEvent} fired as an image is pulled.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class PullImageUpdateEvent extends ProgressUpdateEvent {
private final String id;
@JsonCreator
public PullImageUpdateEvent(String id, String status, ProgressDetail progressDetail, String progress) {
super(status, progressDetail, progress);
this.id = id;
}
/**
* Return the ID of the layer being updated if available.
* @return the ID of the updated layer or {@code null}
*/
public String getId() {
return this.id;
}
}

@ -0,0 +1,88 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.io.PrintStream;
import java.util.function.Consumer;
/**
* Utility to render a simple progress bar based on consumed {@link TotalProgressEvent}
* objects.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class TotalProgressBar implements Consumer<TotalProgressEvent> {
private final char progressChar;
private final boolean bookend;
private final PrintStream out;
private int printed;
/**
* Create a new {@link TotalProgressBar} instance.
* @param prefix the prefix to output
*/
public TotalProgressBar(String prefix) {
this(prefix, System.out);
}
/**
* Create a new {@link TotalProgressBar} instance.
* @param prefix the prefix to output
* @param out the output print stream to use
*/
public TotalProgressBar(String prefix, PrintStream out) {
this(prefix, '#', true, out);
}
/**
* Create a new {@link TotalProgressBar} instance.
* @param prefix the prefix to output
* @param progressChar the progress char to print
* @param bookend if bookends should be printed
* @param out the output print stream to use
*/
public TotalProgressBar(String prefix, char progressChar, boolean bookend, PrintStream out) {
this.progressChar = progressChar;
this.bookend = bookend;
if (prefix != null && prefix.length() > 0) {
out.print(prefix);
out.print(" ");
}
if (bookend) {
out.print("[ ");
}
this.out = out;
}
@Override
public void accept(TotalProgressEvent event) {
int percent = event.getPercent() / 2;
while (this.printed < percent) {
this.out.print(this.progressChar);
this.printed++;
}
if (event.getPercent() == 100) {
this.out.println(this.bookend ? " ]" : "");
}
}
}

@ -0,0 +1,49 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import org.springframework.util.Assert;
/**
* Event published by the {@link TotalProgressPullListener} showing the total progress of
* an operation.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class TotalProgressEvent {
private final int percent;
/**
* Create a new {@link TotalProgressEvent} with a specific percent value.
* @param percent the progress as a percentage
*/
public TotalProgressEvent(int percent) {
Assert.isTrue(percent >= 0 && percent <= 100, "Percent must be in the range 0 to 100");
this.percent = percent;
}
/**
* Return the total progress.
* @return the total progress
*/
public int getPercent() {
return this.percent;
}
}

@ -0,0 +1,142 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import org.springframework.boot.cloudnativebuildpack.docker.ProgressUpdateEvent.ProgressDetail;
/**
* {@link UpdateListener} that calculates the total progress of the entire pull operation
* and publishes {@link TotalProgressEvent}.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class TotalProgressPullListener implements UpdateListener<PullImageUpdateEvent> {
private final Map<String, Layer> layers = new ConcurrentHashMap<>();
private final Consumer<TotalProgressEvent> consumer;
private boolean progressStarted;
/**
* Create a new {@link TotalProgressPullListener} that prints a progress bar to
* {@link System#out}.
* @param prefix the prefix to output
*/
public TotalProgressPullListener(String prefix) {
this(new TotalProgressBar(prefix));
}
/**
* Create a new {@link TotalProgressPullListener} that sends {@link TotalProgressEvent
* events} to the given consumer.
* @param consumer the consumer that receives {@link TotalProgressEvent progress
* events}
*/
public TotalProgressPullListener(Consumer<TotalProgressEvent> consumer) {
this.consumer = consumer;
}
@Override
public void onStart() {
}
@Override
public void onUpdate(PullImageUpdateEvent event) {
if (event.getId() != null) {
this.layers.computeIfAbsent(event.getId(), Layer::new).update(event);
}
this.progressStarted = this.progressStarted || event.getProgress() != null;
if (this.progressStarted) {
publish(0);
}
}
@Override
public void onFinish() {
this.layers.values().forEach(Layer::finish);
publish(100);
}
private void publish(int fallback) {
int count = 0;
int total = 0;
for (Layer layer : this.layers.values()) {
count++;
total += layer.getProgress();
}
TotalProgressEvent event = new TotalProgressEvent(
(count != 0) ? withinPercentageBounds(total / count) : fallback);
this.consumer.accept(event);
}
private static int withinPercentageBounds(int value) {
if (value < 0) {
return 0;
}
if (value > 100) {
return 100;
}
return value;
}
/**
* Progress for an individual layer.
*/
private static class Layer {
private int downloadProgress;
private int extractProgress;
Layer(String id) {
}
void update(PullImageUpdateEvent event) {
if (event.getProgressDetail() != null) {
ProgressDetail detail = event.getProgressDetail();
if ("Downloading".equals(event.getStatus())) {
this.downloadProgress = updateProgress(this.downloadProgress, detail);
}
if ("Extracting".equals(event.getStatus())) {
this.extractProgress = updateProgress(this.extractProgress, detail);
}
}
}
private int updateProgress(int current, ProgressDetail detail) {
int result = withinPercentageBounds((int) ((100.0 / detail.getTotal()) * detail.getCurrent()));
return (result > current) ? result : current;
}
void finish() {
this.downloadProgress = 100;
this.extractProgress = 100;
}
int getProgress() {
return withinPercentageBounds((this.downloadProgress + this.extractProgress) / 2);
}
}
}

@ -0,0 +1,28 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
/**
* Base class for update events published by Docker.
*
* @author Phillip Webb
* @since 2.3.0
* @see UpdateListener
*/
public abstract class UpdateEvent {
}

@ -0,0 +1,64 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
/**
* Listener for update events published from the {@link DockerApi}.
*
* @param <E> the update event type
* @author Phillip Webb
* @since 2.3.0
*/
@FunctionalInterface
public interface UpdateListener<E extends UpdateEvent> {
/**
* A no-op update listener.
* @see #none()
*/
UpdateListener<UpdateEvent> NONE = (event) -> {
};
/**
* Called when the operation starts.
*/
default void onStart() {
}
/**
* Called when an update event is available.
* @param event the update event
*/
void onUpdate(E event);
/**
* Called when the operation finishes (with or without error).
*/
default void onFinish() {
}
/**
* A no-op update listener that does nothing.
* @param <E> the event type
* @return a no-op update listener
*/
@SuppressWarnings("unchecked")
static <E extends UpdateEvent> UpdateListener<E> none() {
return (UpdateListener<E>) NONE;
}
}

@ -0,0 +1,20 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A limited Docker API providing the operations needed by pack.
*/
package org.springframework.boot.cloudnativebuildpack.docker;

@ -0,0 +1,182 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.boot.cloudnativebuildpack.json.SharedObjectMapper;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
/**
* Configuration used when creating a new container.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class ContainerConfig {
private final String json;
ContainerConfig(String user, ImageReference image, String command, List<String> args, Map<String, String> labels,
Map<String, String> binds) throws IOException {
Assert.notNull(image, "Image must not be null");
Assert.hasText(command, "Command must not be empty");
ObjectMapper objectMapper = SharedObjectMapper.get();
ObjectNode node = objectMapper.createObjectNode();
if (StringUtils.hasText(user)) {
node.put("User", user);
}
node.put("Image", image.toString());
ArrayNode commandNode = node.putArray("Cmd");
commandNode.add(command);
args.forEach(commandNode::add);
ObjectNode labelsNode = node.putObject("Labels");
labels.forEach(labelsNode::put);
ObjectNode hostConfigNode = node.putObject("HostConfig");
ArrayNode bindsNode = hostConfigNode.putArray("Binds");
binds.forEach((source, dest) -> bindsNode.add(source + ":" + dest));
this.json = objectMapper.writeValueAsString(node);
}
/**
* Write this container configuration to the specified {@link OutputStream}.
* @param outputStream the output stream
* @throws IOException on IO error
*/
public void writeTo(OutputStream outputStream) throws IOException {
StreamUtils.copy(this.json, StandardCharsets.UTF_8, outputStream);
}
@Override
public String toString() {
return this.json;
}
/**
* Factory method to create a {@link ContainerConfig} with specific settings.
* @param imageReference the source image for the container config
* @param update an update callback used to customize the config
* @return a new {@link ContainerConfig} instance
*/
public static ContainerConfig of(ImageReference imageReference, Consumer<Update> update) {
Assert.notNull(imageReference, "ImageReference must not be null");
Assert.notNull(update, "Update must not be null");
return new Update(imageReference).run(update);
}
/**
* Update class used to change data when creating a container config.
*/
public static class Update {
private final ImageReference image;
private String user;
private String command;
private List<String> args = new ArrayList<>();
private Map<String, String> labels = new LinkedHashMap<>();
private Map<String, String> binds = new LinkedHashMap<>();
Update(ImageReference image) {
this.image = image;
}
private ContainerConfig run(Consumer<Update> update) {
update.accept(this);
try {
return new ContainerConfig(this.user, this.image, this.command, this.args, this.labels, this.binds);
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
/**
* Update the container config with a specific user.
* @param user the user to set
*/
public void withUser(String user) {
this.user = user;
}
/**
* Update the container config with a specific command.
* @param command the command to set
* @param args additional arguments to add
* @see #withArgs(String...)
*/
public void withCommand(String command, String... args) {
this.command = command;
withArgs(args);
}
/**
* Update the container config with additional args.
* @param args the arguments to add
*/
public void withArgs(String... args) {
this.args.addAll(Arrays.asList(args));
}
/**
* Update the container config with an additional label.
* @param name the label name
* @param value the label value
*/
public void withLabel(String name, String value) {
this.labels.put(name, value);
}
/**
* Update the container config with an additional bind.
* @param sourceVolume the source volume
* @param dest the bind destination
*/
public void withBind(VolumeName sourceVolume, String dest) {
this.binds.put(sourceVolume.toString(), dest);
}
/**
* Update the container config with an additional bind.
* @param source the bind source
* @param dest the bind destination
*/
public void withBind(String source, String dest) {
this.binds.put(source, dest);
}
}
}

@ -0,0 +1,76 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import org.springframework.boot.cloudnativebuildpack.io.TarArchive;
import org.springframework.util.Assert;
/**
* Additional content that can be written to a created container.
*
* @author Phillip Webb
* @since 2.3.0
*/
public interface ContainerContent {
/**
* Return the actual content to be added.
* @return the content
*/
TarArchive getArchive();
/**
* Return the destination path where the content should be added.
* @return the destination path
*/
String getDestinationPath();
/**
* Factory method to create a new {@link ContainerContent} instance written to the
* root of the container.
* @param archive the archive to add
* @return a new {@link ContainerContent} instance
*/
static ContainerContent of(TarArchive archive) {
return of(archive, "/");
}
/**
* Factory method to create a new {@link ContainerContent} instance.
* @param archive the archive to add
* @param destinationPath the destination path within the container
* @return a new {@link ContainerContent} instance
*/
static ContainerContent of(TarArchive archive, String destinationPath) {
Assert.notNull(archive, "Archive must not be null");
Assert.hasText(destinationPath, "DestinationPath must not be empty");
return new ContainerContent() {
@Override
public TarArchive getArchive() {
return archive;
}
@Override
public String getDestinationPath() {
return destinationPath;
}
};
}
}

@ -0,0 +1,67 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import org.springframework.util.Assert;
/**
* A reference to a Docker container.
*
* @author Phillip Webb
* @since 2.3.0
*/
public final class ContainerReference {
private final String value;
private ContainerReference(String value) {
Assert.hasText(value, "Value must not be empty");
this.value = value;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
ContainerReference other = (ContainerReference) obj;
return this.value.equals(other.value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Override
public String toString() {
return this.value;
}
/**
* Factory method to create a {@link ContainerReference} with a specific value.
* @param value the container reference value
* @return a new container reference instance
*/
public static ContainerReference of(String value) {
return new ContainerReference(value);
}
}

@ -0,0 +1,114 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import java.io.IOException;
import java.io.InputStream;
import java.lang.invoke.MethodHandles;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.boot.cloudnativebuildpack.json.MappedObject;
/**
* Image details as returned from {@code Docker inspect}.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class Image extends MappedObject {
private final List<String> digests;
private final ImageConfig config;
private List<LayerId> layers;
private final String os;
Image(JsonNode node) {
super(node, MethodHandles.lookup());
this.digests = getDigests(getNode().at("/RepoDigests"));
this.config = new ImageConfig(getNode().at("/Config"));
this.layers = extractLayers(valueAt("/RootFS/Layers", String[].class));
this.os = valueAt("/Os", String.class);
}
private List<String> getDigests(JsonNode node) {
if (node.isEmpty()) {
return Collections.emptyList();
}
List<String> digests = new ArrayList<>();
node.forEach((child) -> digests.add(child.asText()));
return Collections.unmodifiableList(digests);
}
private List<LayerId> extractLayers(String[] layers) {
if (layers == null) {
return Collections.emptyList();
}
return Collections.unmodifiableList(Arrays.stream(layers).map(LayerId::of).collect(Collectors.toList()));
}
/**
* Return the digests of the image.
* @return the image digests
*/
public List<String> getDigests() {
return this.digests;
}
/**
* Return image config information.
* @return the image config
*/
public ImageConfig getConfig() {
return this.config;
}
/**
* Return the layer IDs contained in the image.
* @return the layer IDs.
*/
public List<LayerId> getLayers() {
return this.layers;
}
/**
* Return the OS of the image.
* @return the image OS
*/
public String getOs() {
return (this.os != null) ? this.os : "linux";
}
/**
* Create a new {@link Image} instance from the specified JSON content.
* @param content the JSON content
* @return a new {@link Image} instace
* @throws IOException on IO error
*/
public static Image of(InputStream content) throws IOException {
return of(content, Image::new);
}
}

@ -0,0 +1,293 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import java.io.IOException;
import java.io.OutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.boot.cloudnativebuildpack.io.Content;
import org.springframework.boot.cloudnativebuildpack.io.IOConsumer;
import org.springframework.boot.cloudnativebuildpack.io.InspectedContent;
import org.springframework.boot.cloudnativebuildpack.io.Layout;
import org.springframework.boot.cloudnativebuildpack.io.Owner;
import org.springframework.boot.cloudnativebuildpack.io.TarArchive;
import org.springframework.boot.cloudnativebuildpack.json.SharedObjectMapper;
import org.springframework.util.Assert;
/**
* An image archive that can be loaded into Docker.
*
* @author Phillip Webb
* @since 2.3.0
* @see #from(Image, IOConsumer)
* @see <a href="https://github.com/moby/moby/blob/master/image/spec/v1.2.md">Docker Image
* Specification</a>
*/
public class ImageArchive implements TarArchive {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_ZONED_DATE_TIME
.withZone(ZoneOffset.UTC);
private static final IOConsumer<Update> NO_UPDDATES = (update) -> {
};
private final ObjectMapper objectMapper;
private final ImageConfig imageConfig;
private final Instant createDate;
private final ImageReference tag;
private final String os;
private final List<LayerId> existingLayers;
private final List<Layer> newLayers;
ImageArchive(ObjectMapper objectMapper, ImageConfig imageConfig, Instant createDate, ImageReference tag, String os,
List<LayerId> existingLayers, List<Layer> newLayers) {
this.objectMapper = objectMapper;
this.imageConfig = imageConfig;
this.createDate = createDate;
this.tag = tag;
this.os = os;
this.existingLayers = existingLayers;
this.newLayers = newLayers;
}
/**
* Return the image config for the archive.
* @return the image config
*/
public ImageConfig getImageConfig() {
return this.imageConfig;
}
/**
* Return the create data of the archive.
* @return the create date
*/
public Instant getCreateDate() {
return this.createDate;
}
/**
* Return the tag of the archive.
* @return the tag
*/
public ImageReference getTag() {
return this.tag;
}
@Override
public void writeTo(OutputStream outputStream) throws IOException {
TarArchive.of(this::write).writeTo(outputStream);
}
private void write(Layout writer) throws IOException {
List<LayerId> writtenLayers = writeLayers(writer);
String config = writeConfig(writer, writtenLayers);
writeManifest(writer, config, writtenLayers);
}
private List<LayerId> writeLayers(Layout writer) throws IOException {
List<LayerId> writtenLayers = new ArrayList<>();
for (Layer layer : this.newLayers) {
writtenLayers.add(writeLayer(writer, layer));
}
return Collections.unmodifiableList(writtenLayers);
}
private LayerId writeLayer(Layout writer, Layer layer) throws IOException {
LayerId id = layer.getId();
writer.file("/" + id.getHash() + ".tar", Owner.ROOT, layer);
return id;
}
private String writeConfig(Layout writer, List<LayerId> writtenLayers) throws IOException {
try {
ObjectNode config = createConfig(writtenLayers);
String json = this.objectMapper.writeValueAsString(config);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
InspectedContent content = InspectedContent.of(Content.of(json), digest::update);
String name = "/" + LayerId.ofSha256Digest(digest.digest()).getHash() + ".json";
writer.file(name, Owner.ROOT, content);
return name;
}
catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException(ex);
}
}
private ObjectNode createConfig(List<LayerId> writtenLayers) {
ObjectNode config = this.objectMapper.createObjectNode();
config.set("config", this.imageConfig.getNodeCopy());
config.set("created", config.textNode(getCreatedDate()));
config.set("history", createHistory(writtenLayers));
config.set("os", config.textNode(this.os));
config.set("rootfs", createRootFs(writtenLayers));
return config;
}
private String getCreatedDate() {
return DATE_FORMATTER.format(this.createDate);
}
private JsonNode createHistory(List<LayerId> writtenLayers) {
ArrayNode history = this.objectMapper.createArrayNode();
int size = this.existingLayers.size() + writtenLayers.size();
for (int i = 0; i < size; i++) {
history.addObject();
}
return history;
}
private JsonNode createRootFs(List<LayerId> writtenLayers) {
ObjectNode rootFs = this.objectMapper.createObjectNode();
ArrayNode diffIds = rootFs.putArray("diff_ids");
this.existingLayers.stream().map(Object::toString).forEach(diffIds::add);
writtenLayers.stream().map(Object::toString).forEach(diffIds::add);
return rootFs;
}
private void writeManifest(Layout writer, String config, List<LayerId> writtenLayers) throws IOException {
ArrayNode manifest = createManifest(config, writtenLayers);
String manifestJson = this.objectMapper.writeValueAsString(manifest);
writer.file("/manifest.json", Owner.ROOT, Content.of(manifestJson));
}
private ArrayNode createManifest(String config, List<LayerId> writtenLayers) {
ArrayNode manifest = this.objectMapper.createArrayNode();
ObjectNode entry = manifest.addObject();
entry.set("Config", entry.textNode(config));
entry.set("Layers", getManfiestLayers(writtenLayers));
if (this.tag != null) {
entry.set("RepoTags", entry.arrayNode().add(this.tag.toString()));
}
return manifest;
}
private ArrayNode getManfiestLayers(List<LayerId> writtenLayers) {
ArrayNode layers = this.objectMapper.createArrayNode();
for (int i = 0; i < this.existingLayers.size(); i++) {
layers.add("");
}
writtenLayers.stream().map((id) -> id.getHash() + ".tar").forEach(layers::add);
return layers;
}
/**
* Create a new {@link ImageArchive} based on an existing {@link Image}.
* @param image the image that this archive is based on
* @return the new image archive.
* @throws IOException on IO error
*/
public static ImageArchive from(Image image) throws IOException {
return from(image, NO_UPDDATES);
}
/**
* Create a new {@link ImageArchive} based on an existing {@link Image}.
* @param image the image that this archive is based on
* @param update consumer to apply updates
* @return the new image archive.
* @throws IOException on IO error
*/
public static ImageArchive from(Image image, IOConsumer<Update> update) throws IOException {
return new Update(image).applyTo(update);
}
/**
* Update class used to change data when creating an image archive.
*/
public static final class Update {
private final Image image;
private ImageConfig config;
private Instant createDate;
private ImageReference tag;
private final List<Layer> newLayers = new ArrayList<>();
private Update(Image image) {
this.image = image;
this.config = image.getConfig();
}
private ImageArchive applyTo(IOConsumer<Update> update) throws IOException {
update.accept(this);
Instant createDate = (this.createDate != null) ? this.createDate : Instant.now();
return new ImageArchive(SharedObjectMapper.get(), this.config, createDate, this.tag, this.image.getOs(),
this.image.getLayers(), Collections.unmodifiableList(this.newLayers));
}
/**
* Apply updates to the {@link ImageConfig}.
* @param update consumer to apply updates
*/
public void withUpdatedConfig(Consumer<ImageConfig.Update> update) {
this.config = this.config.copy(update);
}
/**
* Add a new layer to the image archive.
* @param layer the layer to add
*/
public void withNewLayer(Layer layer) {
Assert.notNull(layer, "Layer must not be null");
this.newLayers.add(layer);
}
/**
* Set the create date for the image archive.
* @param createDate the create date
*/
public void withCreateDate(Instant createDate) {
Assert.notNull(createDate, "CreateDate must not be null");
this.createDate = createDate;
}
/**
* Set the tag for the image archive.
* @param tag the tag
*/
public void withTag(ImageReference tag) {
Assert.notNull(tag, "Tag must not be null");
this.tag = tag.inTaggedForm();
}
}
}

@ -0,0 +1,122 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import java.lang.invoke.MethodHandles;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Consumer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.boot.cloudnativebuildpack.json.MappedObject;
/**
* Image configuration information.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class ImageConfig extends MappedObject {
private Map<String, String> labels;
private final Map<String, String> configEnv;
@SuppressWarnings("unchecked")
ImageConfig(JsonNode node) {
super(node, MethodHandles.lookup());
this.labels = valueAt("/Labels", Map.class);
this.configEnv = parseConfigEnv();
}
private Map<String, String> parseConfigEnv() {
Map<String, String> env = new LinkedHashMap<>();
String[] entries = valueAt("/Env", String[].class);
for (String entry : entries) {
int i = entry.indexOf('=');
String name = (i != -1) ? entry.substring(0, i) : entry;
String value = (i != -1) ? entry.substring(i + 1) : null;
env.put(name, value);
}
return Collections.unmodifiableMap(env);
}
JsonNode getNodeCopy() {
return super.getNode().deepCopy();
}
/**
* Return the image labels.
* @return the image labels
*/
public Map<String, String> getLabels() {
return this.labels;
}
/**
* Return the image environment variables.
* @return the env
*/
public Map<String, String> getEnv() {
return this.configEnv;
}
/**
* Create an updated copy of this image config.
* @param update consumer to apply updates
* @return an updated image config
*/
public ImageConfig copy(Consumer<Update> update) {
return new Update(this).run(update);
}
/**
* Update class used to change data when creating a copy.
*/
public static final class Update {
private ObjectNode copy;
private Update(ImageConfig source) {
this.copy = source.getNode().deepCopy();
}
private ImageConfig run(Consumer<Update> update) {
update.accept(this);
return new ImageConfig(this.copy);
}
/**
* Update the image config with an additional label.
* @param label the label name
* @param value the label value
*/
public void withLabel(String label, String value) {
JsonNode labels = this.copy.at("/Labels");
if (labels.isMissingNode()) {
labels = this.copy.putObject("Labels");
}
((ObjectNode) labels).put(label, value);
}
}
}

@ -0,0 +1,137 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import org.springframework.util.Assert;
/**
* A Docker image name of the form {@literal "docker.io/library/ubuntu"}.
*
* @author Phillip Webb
* @since 2.3.0
* @see ImageReference
* @see #of(String)
*/
public class ImageName {
private static final String DEFAULT_DOMAIN = "docker.io";
private static final String OFFICAL_REPOSITORY_NAME = "library";
private static final String LEGACY_DOMAIN = "index.docker.io";
private final String domain;
private final String name;
private final String string;
ImageName(String domain, String name) {
Assert.hasText(domain, "Domain must not be empty");
Assert.hasText(name, "Name must not be empty");
this.domain = domain;
this.name = name;
this.string = domain + "/" + name;
}
/**
* Return the domain for this image name.
* @return the domain
*/
public String getDomain() {
return this.domain;
}
/**
* Return the name of this image.
* @return the image name
*/
public String getName() {
return this.name;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
ImageName other = (ImageName) obj;
boolean result = true;
result = result && this.domain.equals(other.domain);
result = result && this.name.equals(other.name);
return result;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + this.domain.hashCode();
result = prime * result + this.name.hashCode();
return result;
}
@Override
public String toString() {
return this.string;
}
public String toLegacyString() {
if (DEFAULT_DOMAIN.equals(this.domain)) {
return LEGACY_DOMAIN + "/" + this.name;
}
return this.string;
}
/**
* Create a new {@link ImageName} from the given value. The following value forms can
* be used:
* <ul>
* <li>{@code name} (maps to {@code docker.io/library/name})</li>
* <li>{@code domain/name}</li>
* <li>{@code domain:port/name}</li>
* </ul>
* @param value the value to parse
* @return an {@link ImageName} instance
*/
public static ImageName of(String value) {
String[] split = split(value);
return new ImageName(split[0], split[1]);
}
static String[] split(String value) {
Assert.hasText(value, "Value must not be empty");
String domain = DEFAULT_DOMAIN;
int firstSlash = value.indexOf('/');
if (firstSlash != -1) {
String firstSegment = value.substring(0, firstSlash);
if (firstSegment.contains(".") || firstSegment.contains(":") || "localhost".equals(firstSegment)) {
domain = LEGACY_DOMAIN.equals(firstSegment) ? DEFAULT_DOMAIN : firstSegment;
value = value.substring(firstSlash + 1);
}
}
if (DEFAULT_DOMAIN.equals(domain) && !value.contains("/")) {
value = OFFICAL_REPOSITORY_NAME + "/" + value;
}
return new String[] { domain, value };
}
}

@ -0,0 +1,268 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import java.io.File;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* A reference to a Docker image of the form {@code "imagename[:tag|@digest]"}.
*
* @author Phillip Webb
* @since 2.3.0
* @see ImageName
* @see <a href=
* "https://stackoverflow.com/questions/37861791/how-are-docker-image-names-parsed">How
* are Docker image names parsed?</a>
*/
public final class ImageReference {
private static final String LATEST = "latest";
private static final Pattern TRAILING_VERSION_PATTERN = Pattern.compile("^(.*)(\\-\\d+)$");
private final ImageName name;
private final String tag;
private final String digest;
private final String string;
private ImageReference(ImageName name, String tag, String digest) {
Assert.notNull(name, "Name must not be null");
this.name = name;
this.tag = tag;
this.digest = digest;
this.string = buildString(name.toString(), tag, digest);
}
/**
* Return the domain for this image name.
* @return the domain
* @see ImageName#getDomain()
*/
public String getDomain() {
return this.name.getDomain();
}
/**
* Return the name of this image.
* @return the image name
* @see ImageName#getName()
*/
public String getName() {
return this.name.getName();
}
/**
* Return the tag from the reference or {@code null}.
* @return the referenced tag
*/
public String getTag() {
return this.tag;
}
/**
* Return the digest from the reference or {@code null}.
* @return the referenced digest
*/
public String getDigest() {
return this.digest;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
ImageReference other = (ImageReference) obj;
boolean result = true;
result = result && this.name.equals(other.name);
result = result && ObjectUtils.nullSafeEquals(this.tag, other.tag);
result = result && ObjectUtils.nullSafeEquals(this.digest, other.digest);
return result;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + this.name.hashCode();
result = prime * result + ObjectUtils.nullSafeHashCode(this.tag);
result = prime * result + ObjectUtils.nullSafeHashCode(this.digest);
return result;
}
@Override
public String toString() {
return this.string;
}
public String toLegacyString() {
return buildString(this.name.toLegacyString(), this.tag, this.digest);
}
private String buildString(String name, String tag, String digest) {
StringBuilder string = new StringBuilder(name);
if (tag != null) {
string.append(":").append(tag);
}
if (digest != null) {
string.append("@").append(digest);
}
return string.toString();
}
/**
* Create a new {@link ImageReference} with an updated digest.
* @param digest the new digest
* @return an updated image reference
*/
public ImageReference withDigest(String digest) {
return new ImageReference(this.name, null, digest);
}
/**
* Return an {@link ImageReference} in the form {@code "imagename:tag"}. If the tag
* has not been defined then {@code latest} is used.
* @return the image reference in tagged form
* @throws IllegalStateException if the image reference contains a 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);
}
/**
* Create a new {@link ImageReference} instance deduced from a source JAR file that
* follows common Java naming conventions.
* @param jarFile the source jar file
* @return an {@link ImageName} for the jar file.
*/
public static ImageReference forJarFile(File jarFile) {
String filename = jarFile.getName();
Assert.isTrue(filename.toLowerCase().endsWith(".jar"), "File '" + jarFile + "' is not a JAR");
filename = filename.substring(0, filename.length() - 4);
int firstDot = filename.indexOf('.');
if (firstDot == -1) {
return ImageReference.of(filename);
}
String name = filename.substring(0, firstDot);
String version = filename.substring(firstDot + 1);
Matcher matcher = TRAILING_VERSION_PATTERN.matcher(name);
if (matcher.matches()) {
name = matcher.group(1);
version = matcher.group(2).substring(1) + "." + version;
}
return of(ImageName.of(name), version);
}
/**
* Generate an image name with a random suffix.
* @param prefix the name prefix
* @return a random image reference
*/
public static ImageReference random(String prefix) {
return ImageReference.random(prefix, 10);
}
/**
* Generate an image name with a random suffix.
* @param prefix the name prefix
* @param randomLength the number of chars in the random part of the name
* @return a random image reference
*/
public static ImageReference random(String prefix, int randomLength) {
return of(RandomString.generate(prefix, randomLength));
}
/**
* Create a new {@link ImageReference} from the given value. The following value forms
* can be used:
* <ul>
* <li>{@code name} (maps to {@code docker.io/library/name})</li>
* <li>{@code domain/name}</li>
* <li>{@code domain:port/name}</li>
* <li>{@code domain:port/name:tag}</li>
* <li>{@code domain:port/name@digest}</li>
* </ul>
* @param value the value to parse
* @return an {@link ImageName} instance
*/
public static ImageReference of(String value) {
Assert.hasText(value, "Value must not be null");
String[] domainAndValue = ImageName.split(value);
return of(domainAndValue[0], domainAndValue[1]);
}
/**
* Create a new {@link ImageReference} from the given {@link ImageName}.
* @param name the image name
* @return a new image reference
*/
public static ImageReference of(ImageName name) {
return new ImageReference(name, null, null);
}
/**
* Create a new {@link ImageReference} from the given {@link ImageName} and tag.
* @param name the image name
* @param tag the referenced tag
* @return a new image reference
*/
public static ImageReference of(ImageName name, String tag) {
return new ImageReference(name, tag, null);
}
/**
* Create a new {@link ImageReference} from the given {@link ImageName}, tag and
* digest.
* @param name the image name
* @param tag the referenced tag
* @param digest the referenced digest
* @return a new image reference
*/
public static ImageReference of(ImageName name, String tag, String digest) {
return new ImageReference(name, tag, digest);
}
private static ImageReference of(String domain, String value) {
String digest = null;
int lastAt = value.indexOf('@');
if (lastAt != -1) {
digest = value.substring(lastAt + 1);
value = value.substring(0, lastAt);
}
String tag = null;
int firstColon = value.indexOf(':');
if (firstColon != -1) {
tag = value.substring(firstColon + 1);
value = value.substring(0, firstColon);
}
ImageName name = new ImageName(domain, value);
return new ImageReference(name, tag, digest);
}
}

@ -0,0 +1,94 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import java.io.IOException;
import java.io.OutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import org.springframework.boot.cloudnativebuildpack.io.Content;
import org.springframework.boot.cloudnativebuildpack.io.IOConsumer;
import org.springframework.boot.cloudnativebuildpack.io.InspectedContent;
import org.springframework.boot.cloudnativebuildpack.io.Layout;
import org.springframework.boot.cloudnativebuildpack.io.TarArchive;
import org.springframework.util.Assert;
/**
* A layer that can be written to an {@link ImageArchive}.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class Layer implements Content {
private final Content content;
private final LayerId id;
Layer(TarArchive tarArchive) throws NoSuchAlgorithmException, IOException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
this.content = InspectedContent.of(tarArchive::writeTo, digest::update);
this.id = LayerId.ofSha256Digest(digest.digest());
}
/**
* Return the ID of the layer.
* @return the layer ID
*/
public LayerId getId() {
return this.id;
}
@Override
public int size() {
return this.content.size();
}
@Override
public void writeTo(OutputStream outputStream) throws IOException {
this.content.writeTo(outputStream);
}
/**
* Factory method to create a new {@link Layer} with a specific {@link Layout}.
* @param layout the layer layout
* @return a new layer instance
* @throws IOException on IO error
*/
public static Layer of(IOConsumer<Layout> layout) throws IOException {
Assert.notNull(layout, "Layout must not be null");
return fromTarArchive(TarArchive.of(layout));
}
/**
* Factory method to create a new {@link Layer} from a {@link TarArchive}.
* @param tarArchive the contents of the layer
* @return a new layer instance
* @throws IOException on error
*/
public static Layer fromTarArchive(TarArchive tarArchive) throws IOException {
Assert.notNull(tarArchive, "TarArchive must not be null");
try {
return new Layer(tarArchive);
}
catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException(ex);
}
}
}

@ -0,0 +1,105 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import java.math.BigInteger;
import org.springframework.util.Assert;
/**
* A layer ID as used inside a Docker image of the form {@code algorithm: hash}.
*
* @author Phillip Webb
* @since 2.3.0
*/
public final class LayerId {
private final String value;
private final String algorithm;
private final String hash;
private LayerId(String value, String algorithm, String hash) {
this.value = value;
this.algorithm = algorithm;
this.hash = hash;
}
/**
* Return the algorithm of layer.
* @return the algorithm
*/
public String getAlgorithm() {
return this.algorithm;
}
/**
* Return the hash of the layer.
* @return the layer hash
*/
public String getHash() {
return this.hash;
}
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
return this.value.equals(((LayerId) obj).value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Override
public String toString() {
return this.value;
}
/**
* Create a new {@link LayerId} with the specified value.
* @param value the layer ID value of the form {@code algorithm: hash}
* @return a new layer ID instance
*/
public static LayerId of(String value) {
Assert.hasText(value, "Value must not be empty");
int i = value.indexOf(':');
Assert.isTrue(i >= 0, "Invalid layer ID '" + value + "'");
return new LayerId(value, value.substring(0, i), value.substring(i + 1));
}
/**
* Create a new {@link LayerId} from a SHA-256 digest.
* @param digest the digest
* @return a new layer ID instance
*/
public static LayerId ofSha256Digest(byte[] digest) {
Assert.notNull(digest, "Digest must not be null");
Assert.isTrue(digest.length == 32, "Digest must be exactly 32 bytes");
String algorithm = "sha256";
String hash = String.format("%32x", new BigInteger(1, digest));
return new LayerId(algorithm + ":" + hash, algorithm, hash);
}
}

@ -0,0 +1,46 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import java.util.Random;
import java.util.stream.IntStream;
import org.springframework.util.Assert;
/**
* Utility class used to generate random strings.
*
* @author Phillip Webb
*/
final class RandomString {
private static final Random random = new Random();
private RandomString() {
}
static String generate(String prefix, int randomLength) {
Assert.notNull(prefix, "Prefix must not be null");
return prefix + generateRandom(randomLength);
}
static CharSequence generateRandom(int length) {
IntStream chars = random.ints('a', 'z' + 1).limit(length);
return chars.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append);
}
}

@ -0,0 +1,143 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.function.Function;
import org.springframework.util.Assert;
/**
* A Docker volume name.
*
* @author Phillip Webb
* @since 2.3.0
*/
public final class VolumeName {
private final String value;
private VolumeName(String value) {
this.value = value;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
return this.value.equals(((VolumeName) obj).value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Override
public String toString() {
return this.value;
}
/**
* Factory method to create a new {@link VolumeName} with a random name.
* @param prefix the prefix to use with the random name
* @return a randomly named volume
*/
public static VolumeName random(String prefix) {
return random(prefix, 10);
}
/**
* Factory method to create a new {@link VolumeName} with a random name.
* @param prefix the prefix to use with the random name
* @param randomLength the number of chars in the random part of the name
* @return a randomly volume reference
*/
public static VolumeName random(String prefix, int randomLength) {
return of(RandomString.generate(prefix, randomLength));
}
/**
* Factory method to create a new {@link VolumeName} based on an object. The resulting
* name will be based off a SHA-256 digest of the given object's {@code toString()}
* method.
* @param <S> the source object type
* @param source the source object
* @param prefix the prefix to use with the volume name
* @param suffix the suffix to use with the volume name
* @param digestLength the number of chars in the digest part of the name
* @return a name based off the image reference
*/
public static <S> VolumeName basedOn(S source, String prefix, String suffix, int digestLength) {
return basedOn(source, Object::toString, prefix, suffix, digestLength);
}
/**
* Factory method to create a new {@link VolumeName} based on an object. The resulting
* name will be based off a SHA-256 digest of the given object's name.
* @param <S> the source object type
* @param source the source object
* @param nameExtractor a method to extract the name of the object
* @param prefix the prefix to use with the volume name
* @param suffix the suffix to use with the volume name
* @param digestLength the number of chars in the digest part of the name
* @return a name based off the image reference
*/
public static <S> VolumeName basedOn(S source, Function<S, String> nameExtractor, String prefix, String suffix,
int digestLength) {
Assert.notNull(source, "Source must not be null");
Assert.notNull(nameExtractor, "NameExtractor must not be null");
Assert.notNull(prefix, "Prefix must not be null");
Assert.notNull(suffix, "Suffix must not be null");
return of(prefix + getDigest(nameExtractor.apply(source), digestLength) + suffix);
}
private static String getDigest(String name, int length) {
try {
MessageDigest digest = MessageDigest.getInstance("sha-256");
return asHexString(digest.digest(name.getBytes(StandardCharsets.UTF_8)), length);
}
catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException(ex);
}
}
private static String asHexString(byte[] digest, int digestLength) {
Assert.isTrue(digestLength <= digest.length, "DigestLength must be less than or equal to " + digest.length);
byte[] shortDigest = new byte[6];
System.arraycopy(digest, 0, shortDigest, 0, shortDigest.length);
return String.format("%0" + digestLength + "x", new BigInteger(1, shortDigest));
}
/**
* Factory method to create a {@link VolumeName} with a specific value.
* @param value the volme reference value
* @return a new {@link VolumeName} instance
*/
public static VolumeName of(String value) {
Assert.notNull(value, "Value must not be null");
return new VolumeName(value);
}
}

@ -0,0 +1,20 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Docker types.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;

@ -0,0 +1,101 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.io;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import org.springframework.util.Assert;
import org.springframework.util.FileCopyUtils;
/**
* Content with a known size that can be written to an {@link OutputStream}.
*
* @author Phillip Webb
* @since 2.3.0
*/
public interface Content {
/**
* The size of the content in bytes.
* @return the content size
*/
int size();
/**
* Write the content to the given output stream.
* @param outputStream the output stream to write to
* @throws IOException on IO error
*/
void writeTo(OutputStream outputStream) throws IOException;
/**
* Create a new {@link Content} from the given UTF-8 string.
* @param string the string to write
* @return a new {@link Content} instance
*/
static Content of(String string) {
Assert.notNull(string, "String must not be null");
return of(string.getBytes(StandardCharsets.UTF_8));
}
/**
* Create a new {@link Content} from the given input stream.
* @param bytes the bytes to write
* @return a new {@link Content} instance
*/
static Content of(byte[] bytes) {
Assert.notNull(bytes, "Bytes must not be null");
return of(bytes.length, () -> new ByteArrayInputStream(bytes));
}
static Content of(File file) {
Assert.notNull(file, "File must not be null");
return of((int) file.length(), () -> new FileInputStream(file));
}
/**
* Create a new {@link Content} from the given input stream. The stream will be closed
* after it has been written.
* @param size the size of the supplied input stream
* @param supplier the input stream supplier
* @return a new {@link Content} instance
*/
static Content of(int size, IOSupplier<InputStream> supplier) {
Assert.isTrue(size >= 0, "Size must not be negative");
Assert.notNull(supplier, "Supplier must not be null");
return new Content() {
@Override
public int size() {
return size;
}
@Override
public void writeTo(OutputStream outputStream) throws IOException {
FileCopyUtils.copy(supplier.get(), outputStream);
}
};
}
}

@ -0,0 +1,51 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.io;
/**
* Default {@link Owner} implementation.
*
* @author Phillip Webb
* @see Owner#of(long, long)
*/
class DefaultOwner implements Owner {
private final long uid;
private final long gid;
DefaultOwner(long uid, long gid) {
this.uid = uid;
this.gid = gid;
}
@Override
public long getUid() {
return this.uid;
}
@Override
public long getGid() {
return this.gid;
}
@Override
public String toString() {
return this.uid + "/" + this.gid;
}
}

@ -0,0 +1,38 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.io;
import java.io.IOException;
/**
* Consumer that can safely throw {@link IOException IO exceptions}.
*
* @param <T> the consumed type
* @author Phillip Webb
* @since 2.3.0
*/
@FunctionalInterface
public interface IOConsumer<T> {
/**
* Performs this operation on the given argument.
* @param t the instance to consume
* @throws IOException on IO error
*/
void accept(T t) throws IOException;
}

@ -0,0 +1,38 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.io;
import java.io.IOException;
/**
* Supplier that can safely throw {@link IOException IO exceptions}.
*
* @param <T> the supplied type
* @author Phillip Webb
* @since 2.3.0
*/
@FunctionalInterface
public interface IOSupplier<T> {
/**
* Gets the supplied value.
* @return the supplied value
* @throws IOException on IO error
*/
T get() throws IOException;
}

@ -0,0 +1,187 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.io;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import org.springframework.util.Assert;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.StreamUtils;
/**
* {@link Content} that is reads and inspects a source of data only once but allows it to
* be consumed multiple times.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class InspectedContent implements Content {
static final int MEMORY_LIMIT = 4 * 1024 + 3;
private final int size;
private final Object content;
InspectedContent(int size, Object content) {
this.size = size;
this.content = content;
}
@Override
public int size() {
return this.size;
}
@Override
public void writeTo(OutputStream outputStream) throws IOException {
if (this.content instanceof byte[]) {
FileCopyUtils.copy((byte[]) this.content, outputStream);
}
else if (this.content instanceof File) {
FileCopyUtils.copy(new FileInputStream((File) this.content), outputStream);
}
else {
throw new IllegalStateException("Unknown content type");
}
}
/**
* Factory method to create an {@link InspectedContent} instance from a source input
* stream.
* @param inputStream the content input stream
* @param inspectors any inspectors to apply
* @return a new inspected content instance
* @throws IOException on IO error
*/
public static InspectedContent of(InputStream inputStream, Inspector... inspectors) throws IOException {
Assert.notNull(inputStream, "InputStream must not be null");
return of((outputStream) -> FileCopyUtils.copy(inputStream, outputStream), inspectors);
}
/**
* Factory method to create an {@link InspectedContent} instance from source content.
* @param content the content
* @param inspectors any inspectors to apply
* @return a new inspected content instance
* @throws IOException on IO error
*/
public static InspectedContent of(Content content, Inspector... inspectors) throws IOException {
Assert.notNull(content, "Content must not be null");
return of(content::writeTo, inspectors);
}
/**
* Factory method to create an {@link InspectedContent} instance from a source write
* method.
* @param writer a consumer representing the write method
* @param inspectors any inspectors to apply
* @return a new inspected content instance
* @throws IOException on IO error
*/
public static InspectedContent of(IOConsumer<OutputStream> writer, Inspector... inspectors) throws IOException {
Assert.notNull(writer, "Writer must not be null");
InspectingOutputStream outputStream = new InspectingOutputStream(inspectors);
try {
writer.accept(outputStream);
}
finally {
outputStream.close();
}
return new InspectedContent(outputStream.getSize(), outputStream.getContent());
}
/**
* Interface that can be used to inspect content as it is initially read.
*/
public interface Inspector {
/**
* Update inspected information based on the provided bytes.
* @param input the array of bytes.
* @param offset the offset to start from in the array of bytes.
* @param len the number of bytes to use, starting at {@code offset}.
* @throws IOException on IO error
*/
void update(byte[] input, int offset, int len) throws IOException;
}
/**
* Internal {@link OutputStream} used to capture the content either as bytes, or to a
* File if the content is too large.
*/
private static final class InspectingOutputStream extends OutputStream {
private final Inspector[] inspectors;
private int size;
private OutputStream delegate;
private File tempFile;
private byte[] singleByteBuffer = new byte[0];
private InspectingOutputStream(Inspector[] inspectors) {
this.inspectors = inspectors;
this.delegate = new ByteArrayOutputStream();
}
@Override
public void write(int b) throws IOException {
this.singleByteBuffer[0] = (byte) (b & 0xFF);
write(this.singleByteBuffer);
}
@Override
public void write(byte[] b, int off, int len) throws IOException {
int size = len - off;
if (this.tempFile == null && (this.size + size) > MEMORY_LIMIT) {
convertToTempFile();
}
this.delegate.write(b, off, len);
for (Inspector inspector : this.inspectors) {
inspector.update(b, off, len);
}
this.size += size;
}
private void convertToTempFile() throws IOException {
this.tempFile = File.createTempFile("buildpack", ".tmp");
byte[] bytes = ((ByteArrayOutputStream) this.delegate).toByteArray();
this.delegate = new FileOutputStream(this.tempFile);
StreamUtils.copy(bytes, this.delegate);
}
private Object getContent() {
return (this.tempFile != null) ? this.tempFile : ((ByteArrayOutputStream) this.delegate).toByteArray();
}
private int getSize() {
return this.size;
}
}
}

@ -0,0 +1,46 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.io;
import java.io.IOException;
/**
* Interface that can be used to write a file/folder layout.
*
* @author Phillip Webb
* @since 2.3.0
*/
public interface Layout {
/**
* Add a folder to the content.
* @param name the full name of the folder to add.
* @param owner the owner of the folder
* @throws IOException on IO error
*/
void folder(String name, Owner owner) throws IOException;
/**
* Write a file to the content.
* @param name the full name of the file to add.
* @param owner the owner of the folder
* @param content the content to add
* @throws IOException on IO error
*/
void file(String name, Owner owner, Content content) throws IOException;
}

@ -0,0 +1,54 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.io;
/**
* A user and group ID that can be used to indicate file ownership.
*
* @author Phillip Webb
* @since 2.3.0
*/
public interface Owner {
/**
* Owner for root ownership.
*/
Owner ROOT = Owner.of(0, 0);
/**
* Return the user identifier (UID) of the owner.
* @return the user identifier
*/
long getUid();
/**
* Return the group identifier (GID) of the owner.
* @return the group identifier
*/
long getGid();
/**
* Factory method to create a new {@link Owner} with specified user/group identifier.
* @param uid the user identifier
* @param gid the group identifier
* @return a new {@link Owner} instance
*/
static Owner of(long uid, long gid) {
return new DefaultOwner(uid, gid);
}
}

@ -0,0 +1,71 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.io;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
/**
* A TAR archive that can be written to an output stream.
*
* @author Phillip Webb
* @since 2.3.0
*/
@FunctionalInterface
public interface TarArchive {
/**
* {@link Instant} that can be used to normalize TAR files so all entries have the
* same modification time.
*/
Instant NORMALIZED_TIME = OffsetDateTime.of(1980, 1, 1, 0, 0, 1, 0, ZoneOffset.UTC).toInstant();
/**
* Write the TAR archive to the given output stream.
* @param outputStream the output stream to write to
* @throws IOException on IO error
*/
void writeTo(OutputStream outputStream) throws IOException;
/**
* Factory method to create a new {@link TarArchive} instance with a specific layout.
* @param layout the TAR layout
* @return a new {@link TarArchive} instance
*/
static TarArchive of(IOConsumer<Layout> layout) {
return (outputStream) -> {
TarLayoutWriter writer = new TarLayoutWriter(outputStream);
layout.accept(writer);
writer.finish();
};
}
/**
* Factory method to adapt a ZIP file to {@link TarArchive}.
* @param zip the source zip file
* @param owner the owner of the entries in the TAR
* @return a new {@link TarArchive} instance
*/
static TarArchive fromZip(File zip, Owner owner) {
return new ZipFileTarArchive(zip, owner);
}
}

@ -0,0 +1,84 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.io;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.commons.compress.archivers.tar.TarConstants;
import org.springframework.util.StreamUtils;
/**
* {@link Layout} for writing TAR archive content directly to an {@link OutputStream}.
*
* @author Phillip Webb
*/
class TarLayoutWriter implements Layout, Closeable {
static final long NORMALIZED_MOD_TIME = TarArchive.NORMALIZED_TIME.toEpochMilli();
private TarArchiveOutputStream outputStream;
TarLayoutWriter(OutputStream outputStream) {
this.outputStream = new TarArchiveOutputStream(outputStream);
}
@Override
public void folder(String name, Owner owner) throws IOException {
this.outputStream.putArchiveEntry(createFolderEntry(name, owner));
this.outputStream.closeArchiveEntry();
}
@Override
public void file(String name, Owner owner, Content content) throws IOException {
this.outputStream.putArchiveEntry(createFileEntry(name, owner, content.size()));
content.writeTo(StreamUtils.nonClosing(this.outputStream));
this.outputStream.closeArchiveEntry();
}
private TarArchiveEntry createFolderEntry(String name, Owner owner) {
return createEntry(name, owner, TarConstants.LF_DIR, 0755, 0);
}
private TarArchiveEntry createFileEntry(String name, Owner owner, int size) {
return createEntry(name, owner, TarConstants.LF_NORMAL, 0644, size);
}
private TarArchiveEntry createEntry(String name, Owner owner, byte linkFlag, int mode, int size) {
TarArchiveEntry entry = new TarArchiveEntry(name, linkFlag, true);
entry.setUserId(owner.getUid());
entry.setGroupId(owner.getGid());
entry.setMode(mode);
entry.setModTime(NORMALIZED_MOD_TIME);
entry.setSize(size);
return entry;
}
void finish() throws IOException {
this.outputStream.finish();
}
@Override
public void close() throws IOException {
this.outputStream.close();
}
}

@ -0,0 +1,88 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.io;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Enumeration;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.commons.compress.archivers.tar.TarConstants;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
/**
* Adapter class to convert a ZIP file to a {@link TarArchive}.
*
* @author Phillip Webb
*/
class ZipFileTarArchive implements TarArchive {
static final long NORMALIZED_MOD_TIME = TarArchive.NORMALIZED_TIME.toEpochMilli();
private final File zip;
private final Owner owner;
ZipFileTarArchive(File zip, Owner owner) {
Assert.notNull(zip, "Zip must not be null");
Assert.notNull(owner, "Owner must not be null");
this.zip = zip;
this.owner = owner;
}
@Override
public void writeTo(OutputStream outputStream) throws IOException {
TarArchiveOutputStream tar = new TarArchiveOutputStream(outputStream);
try (ZipFile zipFile = new ZipFile(this.zip)) {
Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
while (entries.hasMoreElements()) {
ZipArchiveEntry zipEntry = entries.nextElement();
copy(zipEntry, zipFile.getInputStream(zipEntry), tar);
}
}
tar.finish();
}
private void copy(ZipArchiveEntry zipEntry, InputStream zip, TarArchiveOutputStream tar) throws IOException {
TarArchiveEntry tarEntry = convert(zipEntry);
tar.putArchiveEntry(tarEntry);
if (tarEntry.isFile()) {
StreamUtils.copyRange(zip, tar, 0, tarEntry.getSize());
}
tar.closeArchiveEntry();
}
private TarArchiveEntry convert(ZipArchiveEntry zipEntry) {
byte linkFlag = (zipEntry.isDirectory()) ? TarConstants.LF_DIR : TarConstants.LF_NORMAL;
TarArchiveEntry tarEntry = new TarArchiveEntry(zipEntry.getName(), linkFlag, true);
tarEntry.setUserId(this.owner.getUid());
tarEntry.setGroupId(this.owner.getGid());
tarEntry.setModTime(NORMALIZED_MOD_TIME);
if (!zipEntry.isDirectory()) {
tarEntry.setSize(zipEntry.getSize());
}
return tarEntry;
}
}

@ -0,0 +1,20 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* IO classes and utilities.
*/
package org.springframework.boot.cloudnativebuildpack.io;

@ -0,0 +1,91 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.json;
import java.io.IOException;
import java.io.InputStream;
import java.util.function.Consumer;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* Utility class that allows JSON to be parsed and processed as it's received.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class JsonStream {
private final ObjectMapper objectMapper;
/**
* Create a new {@link JsonStream} backed by the given object mapper.
* @param objectMapper the object mapper to use
*/
public JsonStream(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* Stream {@link ObjectNode object nodes} from the content as they become available.
* @param content the source content
* @param consumer the {@link ObjectNode} consumer
* @throws IOException on IO error
*/
public void get(InputStream content, Consumer<ObjectNode> consumer) throws IOException {
get(content, ObjectNode.class, consumer);
}
/**
* Stream objects from the content as they become available.
* @param <T> the object type
* @param content the source content
* @param type the object type
* @param consumer the {@link ObjectNode} consumer
* @throws IOException on IO error
*/
public <T> void get(InputStream content, Class<T> type, Consumer<T> consumer) throws IOException {
JsonFactory jsonFactory = this.objectMapper.getFactory();
JsonParser parser = jsonFactory.createParser(content);
while (!parser.isClosed()) {
JsonToken token = parser.nextToken();
if (token != null && token != JsonToken.END_OBJECT) {
T node = read(parser, type);
if (node != null) {
consumer.accept(node);
}
}
}
}
@SuppressWarnings("unchecked")
private <T> T read(JsonParser parser, Class<T> type) throws IOException {
if (ObjectNode.class.isAssignableFrom(type)) {
ObjectNode node = this.objectMapper.readTree(parser);
if (node == null || node.isMissingNode() || node.isEmpty()) {
return null;
}
return (T) node;
}
return this.objectMapper.readValue(parser, type);
}
}

@ -0,0 +1,228 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.json;
import java.io.IOException;
import java.io.InputStream;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles.Lookup;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.function.Function;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.util.Assert;
/**
* Base class for mapped JSON objects.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class MappedObject {
private final JsonNode node;
private final Lookup lookup;
/**
* Create a new {@link MappedObject} instance.
* @param node the source node
* @param lookup method handle lookup
*/
protected MappedObject(JsonNode node, Lookup lookup) {
this.node = node;
this.lookup = lookup;
}
/**
* Return the source node of the mapped object.
* @return the source node
*/
protected final JsonNode getNode() {
return this.node;
}
/**
* Get the value at the given JSON path expression as a specific type.
* @param <T> the data type
* @param expression the JSON path expression
* @param type the desired type. May be a simple JSON type or an interface
* @return the value
*/
protected <T> T valueAt(String expression, Class<T> type) {
return valueAt(this, this.node, this.lookup, expression, type);
}
@SuppressWarnings("unchecked")
protected static <T extends MappedObject> T getRoot(Object proxy) {
MappedInvocationHandler handler = (MappedInvocationHandler) Proxy.getInvocationHandler(proxy);
return (T) handler.root;
}
protected static <T> T valueAt(Object proxy, String expression, Class<T> type) {
MappedInvocationHandler handler = (MappedInvocationHandler) Proxy.getInvocationHandler(proxy);
return valueAt(handler.root, handler.node, handler.lookup, expression, type);
}
@SuppressWarnings("unchecked")
private static <T> T valueAt(MappedObject root, JsonNode node, Lookup lookup, String expression, Class<T> type) {
JsonNode result = node.at(expression);
if (result.isMissingNode() && expression.startsWith("/") && expression.length() > 1
&& Character.isLowerCase(expression.charAt(1))) {
StringBuilder alternative = new StringBuilder(expression);
alternative.setCharAt(1, Character.toUpperCase(alternative.charAt(1)));
result = node.at(alternative.toString());
}
if (type.isInterface() && !type.getName().startsWith("java")) {
return (T) Proxy.newProxyInstance(MappedObject.class.getClassLoader(), new Class<?>[] { type },
new MappedInvocationHandler(root, result, lookup));
}
if (result.isMissingNode()) {
return null;
}
try {
return SharedObjectMapper.get().treeToValue(result, type);
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
/**
* Factory method to create a new {@link MappedObject} instance.
* @param <T> the mapped object type
* @param content the JSON content for the object
* @param factory a factory to create the mapped object from a {@link JsonNode}
* @return the mapped object
* @throws IOException on IO error
*/
protected static <T extends MappedObject> T of(String content, Function<JsonNode, T> factory) throws IOException {
return of(content, ObjectMapper::readTree, factory);
}
/**
* Factory method to create a new {@link MappedObject} instance.
* @param <T> the mapped object type
* @param content the JSON content for the object
* @param factory a factory to create the mapped object from a {@link JsonNode}
* @return the mapped object
* @throws IOException on IO error
*/
protected static <T extends MappedObject> T of(InputStream content, Function<JsonNode, T> factory)
throws IOException {
return of(content, ObjectMapper::readTree, factory);
}
/**
* Factory method to create a new {@link MappedObject} instance.
* @param <T> the mapped object type
* @param <C> the content type
* @param content the JSON content for the object
* @param reader the content reader
* @param factory a factory to create the mapped object from a {@link JsonNode}
* @return the mapped object
* @throws IOException on IO error
*/
protected static <T extends MappedObject, C> T of(C content, ContentReader<C> reader, Function<JsonNode, T> factory)
throws IOException {
ObjectMapper objectMapper = SharedObjectMapper.get();
JsonNode node = reader.read(objectMapper, content);
return factory.apply(node);
}
/**
* Strategy used to read JSON content.
*
* @param <C> the content type
*/
@FunctionalInterface
protected interface ContentReader<C> {
/**
* Read JSON content as a {@link JsonNode}.
* @param objectMapper the source object mapper
* @param content the content to read
* @return a {@link JsonNode}
* @throws IOException on IO error
*/
JsonNode read(ObjectMapper objectMapper, C content) throws IOException;
}
/**
* {@link InvocationHandler} used to support
* {@link MappedObject#valueAt(String, Class) valueAt} with {@code interface} types.
*/
private static class MappedInvocationHandler implements InvocationHandler {
private static final String GET = "get";
private static final String IS = "is";
private final MappedObject root;
private final JsonNode node;
private final Lookup lookup;
MappedInvocationHandler(MappedObject root, JsonNode node, Lookup lookup) {
this.root = root;
this.node = node;
this.lookup = lookup;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Class<?> declaringClass = method.getDeclaringClass();
if (method.isDefault()) {
Lookup lookup = this.lookup.in(declaringClass);
MethodHandle methodHandle = lookup.unreflectSpecial(method, declaringClass).bindTo(proxy);
return methodHandle.invokeWithArguments();
}
if (declaringClass == Object.class) {
method.invoke(proxy, args);
}
Assert.state(args == null || args.length == 0, "Unsupported method " + method);
String name = getName(method.getName());
Class<?> type = method.getReturnType();
return valueForProperty(name, type);
}
private String getName(String name) {
StringBuilder result = new StringBuilder(name);
if (name.startsWith(GET)) {
result = new StringBuilder(name.substring(GET.length()));
}
if (name.startsWith(IS)) {
result = new StringBuilder(name.substring(IS.length()));
}
Assert.state(result.length() >= 0, "Missing name");
result.setCharAt(0, Character.toLowerCase(result.charAt(0)));
return result.toString();
}
private Object valueForProperty(String name, Class<?> type) {
return valueAt(this.root, this.node, this.lookup, "/" + name, type);
}
}
}

@ -0,0 +1,51 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.json;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
/**
* Provides access to a shared pre-configured {@link ObjectMapper}.
*
* @author Phillip Webb
* @since 2.3.0
*/
public final class SharedObjectMapper {
private static final ObjectMapper INSTANCE;
static {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new ParameterNamesModule());
objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.setPropertyNamingStrategy(PropertyNamingStrategy.LOWER_CAMEL_CASE);
INSTANCE = objectMapper;
}
private SharedObjectMapper() {
}
public static ObjectMapper get() {
return INSTANCE;
}
}

@ -0,0 +1,20 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Utilities and classes for JSON processing.
*/
package org.springframework.boot.cloudnativebuildpack.json;

@ -0,0 +1,87 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.socket;
import java.io.IOException;
import java.net.InetAddress;
import java.net.Socket;
import java.net.SocketAddress;
/**
* Abstract base class for custom socket implementation.
*
* @author Phillip Webb
*/
class AbstractSocket extends Socket {
@Override
public void connect(SocketAddress endpoint) throws IOException {
}
@Override
public void connect(SocketAddress endpoint, int timeout) throws IOException {
}
@Override
public boolean isConnected() {
return true;
}
@Override
public boolean isBound() {
return true;
}
@Override
public void shutdownInput() throws IOException {
throw new UnsupportedSocketOperationException();
}
@Override
public void shutdownOutput() throws IOException {
throw new UnsupportedSocketOperationException();
}
@Override
public InetAddress getInetAddress() {
return null;
}
@Override
public InetAddress getLocalAddress() {
return null;
}
@Override
public SocketAddress getLocalSocketAddress() {
return null;
}
@Override
public SocketAddress getRemoteSocketAddress() {
return null;
}
private static class UnsupportedSocketOperationException extends UnsupportedOperationException {
UnsupportedSocketOperationException() {
super("Unsupported socket operation");
}
}
}

@ -0,0 +1,83 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.socket;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import com.sun.jna.LastErrorException;
import com.sun.jna.Native;
import com.sun.jna.Platform;
import com.sun.jna.Structure;
import org.springframework.util.Assert;
/**
* {@link DomainSocket} implementation for BSD based platforms.
*
* @author Phillip Webb
*/
class BsdDomainSocket extends DomainSocket {
private static final int MAX_PATH_LENGTH = 104;
static {
Native.register(Platform.C_LIBRARY_NAME);
}
BsdDomainSocket(String path) throws IOException {
super(path);
}
@Override
protected void connect(String path, int handle) {
SockaddrUn address = new SockaddrUn(AF_LOCAL, path.getBytes(StandardCharsets.UTF_8));
connect(handle, address, address.size());
}
private native int connect(int fd, SockaddrUn address, int addressLen) throws LastErrorException;
/**
* Native {@code sockaddr_un} structure as defined in {@code sys/un.h}.
*/
public static class SockaddrUn extends Structure implements Structure.ByReference {
public byte sunLen;
public byte sunFamily;
public byte[] sunPath = new byte[MAX_PATH_LENGTH];
private SockaddrUn(byte sunFamily, byte[] path) {
Assert.isTrue(path.length < MAX_PATH_LENGTH, "Path cannot exceed " + MAX_PATH_LENGTH + " bytes");
System.arraycopy(path, 0, this.sunPath, 0, path.length);
this.sunPath[path.length] = 0;
this.sunLen = (byte) (fieldOffset("sunPath") + path.length);
this.sunFamily = sunFamily;
allocateMemory();
}
@Override
protected List<String> getFieldOrder() {
return Arrays.asList(new String[] { "sunLen", "sunFamily", "sunPath" });
}
}
}

@ -0,0 +1,195 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.socket;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.ByteBuffer;
import com.sun.jna.LastErrorException;
import com.sun.jna.Native;
import com.sun.jna.Platform;
import org.springframework.boot.cloudnativebuildpack.socket.FileDescriptor.Handle;
/**
* A {@link Socket} implementation for Linux of BSD domain sockets.
*
* @author Phillip Webb
* @since 2.3.0
*/
public abstract class DomainSocket extends AbstractSocket {
private static final int SHUT_RD = 0;
private static final int SHUT_WR = 1;
protected static final int PF_LOCAL = 1;
protected static final byte AF_LOCAL = 1;
protected static final int SOCK_STREAM = 1;
private final FileDescriptor fileDescriptor;
private final InputStream inputStream;
private final OutputStream outputStream;
static {
Native.register(Platform.C_LIBRARY_NAME);
}
DomainSocket(String path) throws IOException {
try {
this.fileDescriptor = open(path);
this.inputStream = new DomainSocketInputStream();
this.outputStream = new DomainSocketOutputStream();
}
catch (LastErrorException ex) {
throw new IOException(ex);
}
}
private FileDescriptor open(String path) {
int handle = socket(PF_LOCAL, SOCK_STREAM, 0);
connect(path, handle);
return new FileDescriptor(handle, this::close);
}
private int read(ByteBuffer buffer) throws IOException {
try (Handle handle = this.fileDescriptor.acquire()) {
if (handle.isClosed()) {
return -1;
}
try {
return read(handle.intValue(), buffer, buffer.remaining());
}
catch (LastErrorException ex) {
throw new IOException(ex);
}
}
}
public void write(ByteBuffer buffer) throws IOException {
try (Handle handle = this.fileDescriptor.acquire()) {
if (!handle.isClosed()) {
try {
write(handle.intValue(), buffer, buffer.remaining());
}
catch (LastErrorException ex) {
throw new IOException(ex);
}
}
}
}
@Override
public InputStream getInputStream() {
return this.inputStream;
}
@Override
public OutputStream getOutputStream() {
return this.outputStream;
}
@Override
public void close() throws IOException {
super.close();
try {
this.fileDescriptor.close();
}
catch (LastErrorException ex) {
throw new IOException(ex);
}
}
protected abstract void connect(String path, int handle);
private native int socket(int domain, int type, int protocol) throws LastErrorException;
private native int read(int fd, ByteBuffer buffer, int count) throws LastErrorException;
private native int write(int fd, ByteBuffer buffer, int count) throws LastErrorException;
private native int close(int fd) throws LastErrorException;
/**
* Return a new {@link DomainSocket} for the given path.
* @param path the path to the domain socket
* @return a {@link DomainSocket} instance
* @throws IOException if the socket cannot be opened
*/
public static DomainSocket get(String path) throws IOException {
if (Platform.isMac() || isBsdPlatform()) {
return new BsdDomainSocket(path);
}
return new LinuxDomainSocket(path);
}
private static boolean isBsdPlatform() {
return Platform.isFreeBSD() || Platform.iskFreeBSD() || Platform.isNetBSD() || Platform.isOpenBSD();
}
/**
* {@link InputStream} returned from the {@link DomainSocket}.
*/
private class DomainSocketInputStream extends InputStream {
@Override
public int read() throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1);
int amountRead = DomainSocket.this.read(buffer);
return (amountRead != 1) ? -1 : buffer.get() & 0xFF;
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
if (len == 0) {
return 0;
}
int amountRead = DomainSocket.this.read(ByteBuffer.wrap(b, off, len));
return (amountRead > 0) ? amountRead : -1;
}
}
/**
* {@link OutputStream} returned from the {@link DomainSocket}.
*/
private class DomainSocketOutputStream extends OutputStream {
@Override
public void write(int b) throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1);
buffer.put(0, (byte) (b & 0xFF));
DomainSocket.this.write(buffer);
}
@Override
public void write(byte[] b, int off, int len) throws IOException {
if (len != 0) {
DomainSocket.this.write(ByteBuffer.wrap(b, off, len));
}
}
}
}

@ -0,0 +1,123 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.socket;
import java.io.Closeable;
import java.io.IOException;
import java.util.function.IntConsumer;
/**
* Provides access to an opaque to the underling file system representation of an open
* file.
*
* @author Phillip Webb
* @see #acquire()
*/
class FileDescriptor {
private final Handle openHandle;
private final Handle closedHandler;
private final IntConsumer closer;
private Status status = Status.OPEN;
private int referenceCount;
FileDescriptor(int handle, IntConsumer closer) {
this.openHandle = new Handle(handle);
this.closedHandler = new Handle(-1);
this.closer = closer;
}
@Override
protected void finalize() throws Throwable {
close();
}
/**
* Acquire an instance of the actual {@link Handle}. The caller must
* {@link Handle#close() close} the resulting handle when done.
* @return the handle
*/
synchronized Handle acquire() {
this.referenceCount++;
return (this.status != Status.OPEN) ? this.closedHandler : this.openHandle;
}
private synchronized void release() {
this.referenceCount--;
if (this.referenceCount == 0 && this.status == Status.CLOSE_PENDING) {
this.closer.accept(this.openHandle.value);
this.status = Status.CLOSED;
}
}
/**
* Close the underlying file when all handles have been released.
*/
synchronized void close() {
if (this.status == Status.OPEN) {
if (this.referenceCount == 0) {
this.closer.accept(this.openHandle.value);
this.status = Status.CLOSED;
}
else {
this.status = Status.CLOSE_PENDING;
}
}
}
/**
* The status of the file descriptor.
*/
private enum Status {
OPEN, CLOSE_PENDING, CLOSED;
}
/**
* Provides access to the actual file descriptor handle.
*/
final class Handle implements Closeable {
private final int value;
private Handle(int value) {
this.value = value;
}
boolean isClosed() {
return this.value == -1;
}
int intValue() {
return this.value;
}
@Override
public void close() throws IOException {
if (!isClosed()) {
release();
}
}
}
}

@ -0,0 +1,74 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.socket;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import com.sun.jna.LastErrorException;
import com.sun.jna.Structure;
import org.springframework.util.Assert;
/**
* {@link DomainSocket} implementation for Linux based platforms.
*
* @author Phillip Webb
*/
class LinuxDomainSocket extends DomainSocket {
LinuxDomainSocket(String path) throws IOException {
super(path);
}
private static final int MAX_PATH_LENGTH = 108;
@Override
protected void connect(String path, int handle) {
SockaddrUn address = new SockaddrUn(AF_LOCAL, path.getBytes(StandardCharsets.UTF_8));
connect(handle, address, address.size());
}
private native int connect(int fd, SockaddrUn address, int addressLen) throws LastErrorException;
/**
* Native {@code sockaddr_un} structure as defined in {@code sys/un.h}.
*/
public static class SockaddrUn extends Structure implements Structure.ByReference {
public short sunFamily;
public byte[] sunPath = new byte[MAX_PATH_LENGTH];
private SockaddrUn(byte sunFamily, byte[] path) {
Assert.isTrue(path.length < MAX_PATH_LENGTH, "Path cannot exceed " + MAX_PATH_LENGTH + " bytes");
System.arraycopy(path, 0, this.sunPath, 0, path.length);
this.sunPath[path.length] = 0;
this.sunFamily = sunFamily;
allocateMemory();
}
@Override
protected List<String> getFieldOrder() {
return Arrays.asList(new String[] { "sunLen", "sunFamily", "sunPath" });
}
}
}

@ -0,0 +1,162 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.socket;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.net.Socket;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import com.sun.jna.Platform;
import com.sun.jna.platform.win32.Kernel32;
/**
* A {@link Socket} implementation for named pipes.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class NamedPipeSocket extends Socket {
private static final long TIMEOUT = TimeUnit.MILLISECONDS.toNanos(1000);
private final RandomAccessFile file;
private final InputStream inputStream;
private final OutputStream outputStream;
private final Consumer<String> awaiter;
NamedPipeSocket(String path) throws IOException {
this.file = open(path);
this.inputStream = new NamedPipeInputStream();
this.outputStream = new NamedPipeOutputStream();
this.awaiter = Platform.isWindows() ? new WindowsAwaiter() : new SleepAwaiter();
}
private RandomAccessFile open(String path) throws IOException {
long startTime = System.nanoTime();
while (true) {
try {
return new RandomAccessFile(path, "rw");
}
catch (FileNotFoundException ex) {
if (System.nanoTime() - startTime > TIMEOUT) {
throw ex;
}
this.awaiter.accept(path);
}
}
}
@Override
public InputStream getInputStream() {
return this.inputStream;
}
@Override
public OutputStream getOutputStream() {
return this.outputStream;
}
@Override
public void close() throws IOException {
this.file.close();
}
protected final RandomAccessFile getFile() {
return this.file;
}
/**
* Return a new {@link NamedPipeSocket} for the given path.
* @param path the path to the domain socket
* @return a {@link NamedPipeSocket} instance
* @throws IOException if the socket cannot be opened
*/
public static NamedPipeSocket get(String path) throws IOException {
return new NamedPipeSocket(path);
}
/**
* {@link InputStream} returned from the {@link NamedPipeSocket}.
*/
private class NamedPipeInputStream extends InputStream {
@Override
public int read() throws IOException {
return getFile().read();
}
@Override
public int read(byte[] bytes, int off, int len) throws IOException {
return getFile().read(bytes, off, len);
}
}
/**
* {@link InputStream} returned from the {@link NamedPipeSocket}.
*/
private class NamedPipeOutputStream extends OutputStream {
@Override
public void write(int value) throws IOException {
NamedPipeSocket.this.file.write(value);
}
@Override
public void write(byte[] bytes, int off, int len) throws IOException {
NamedPipeSocket.this.file.write(bytes, off, len);
}
}
/**
* Waits for the name pipe file using a simple sleep.
*/
private class SleepAwaiter implements Consumer<String> {
@Override
public void accept(String path) {
try {
Thread.sleep(100);
}
catch (InterruptedException ex) {
}
}
}
/**
* Waits for the name pipe file using Windows specific logic.
*/
private class WindowsAwaiter implements Consumer<String> {
@Override
public void accept(String path) {
Kernel32.INSTANCE.WaitNamedPipe(path, 100);
}
}
}

@ -0,0 +1,20 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Low-level {@link java.net.Socket} implementations required for local Docker access.
*/
package org.springframework.boot.cloudnativebuildpack.socket;

@ -0,0 +1,59 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.toml;
import java.util.Arrays;
import java.util.stream.Collectors;
/**
* Very simple TOML markup builder.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class Toml {
private final StringBuilder toml = new StringBuilder();
public void table(String name) {
append("[" + name + "]");
}
public void string(String name, String value) {
append(name + " = " + quote(value));
}
public void array(String name, String... value) {
if (value != null && value.length > 0) {
append(name + " = " + Arrays.stream(value).map(this::quote).collect(Collectors.toList()));
}
}
private void append(String line) {
this.toml.append(line).append('\n');
}
private String quote(String string) {
return "\"" + string + "\"";
}
@Override
public String toString() {
return this.toml.toString();
}
}

@ -0,0 +1,20 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Support for writing TOML content.
*/
package org.springframework.boot.cloudnativebuildpack.toml;

@ -0,0 +1,117 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
/**
* Tests for {@link ApiVersion}.
*
* @author Phillip Webb
*/
class ApiVersionTests {
@Test
void parseWhenVersionIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> ApiVersion.parse(null))
.withMessage("Value must not be empty");
}
@Test
void parseWhenVersionIsEmptyThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> ApiVersion.parse(""))
.withMessage("Value must not be empty");
}
@Test
void parseWhenVersionDoesNotMatchPatternThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> ApiVersion.parse("bad"))
.withMessage("Malformed version number 'bad'");
}
@Test
void parseReturnsVersion() {
ApiVersion version = ApiVersion.parse("1.2");
assertThat(version.getMajor()).isEqualTo(1);
assertThat(version.getMinor()).isEqualTo(2);
}
@Test
void assertSupportsWhenSupports() {
ApiVersion.parse("1.2").assertSupports(ApiVersion.parse("1.0"));
}
@Test
void assertSupportsWhenDoesNotSupportThrowsException() {
assertThatIllegalStateException()
.isThrownBy(() -> ApiVersion.parse("1.2").assertSupports(ApiVersion.parse("1.3")))
.withMessage("Version 'v1.3' is not supported by this version ('v1.2')");
}
@Test
void supportWhenSame() {
assertThat(supports("0.0", "0.0")).isTrue();
assertThat(supports("0.1", "0.1")).isTrue();
assertThat(supports("1.0", "1.0")).isTrue();
assertThat(supports("1.1", "1.1")).isTrue();
}
@Test
void supportsWhenDifferentMajor() {
assertThat(supports("0.0", "1.0")).isFalse();
assertThat(supports("1.0", "0.0")).isFalse();
assertThat(supports("1.0", "2.0")).isFalse();
assertThat(supports("2.0", "1.0")).isFalse();
assertThat(supports("1.1", "2.1")).isFalse();
assertThat(supports("2.1", "1.1")).isFalse();
}
@Test
void supportsWhenDifferentMinor() {
assertThat(supports("1.2", "1.1")).isTrue();
assertThat(supports("1.2", "1.3")).isFalse();
}
@Test
void supportWhenMajorZeroAndDifferentMinor() {
assertThat(supports("0.2", "0.1")).isFalse();
assertThat(supports("0.2", "0.3")).isFalse();
}
@Test
void toStringReturnsString() {
assertThat(ApiVersion.parse("1.2").toString()).isEqualTo("v1.2");
}
@Test
void equalsAndHashCode() {
ApiVersion v12a = ApiVersion.parse("1.2");
ApiVersion v12b = ApiVersion.parse("1.2");
ApiVersion v13 = ApiVersion.parse("1.3");
assertThat(v12a.hashCode()).isEqualTo(v12b.hashCode());
assertThat(v12a).isEqualTo(v12a).isEqualTo(v12b).isNotEqualTo(v13);
}
private boolean supports(String v1, String v2) {
return ApiVersion.parse(v1).supports(ApiVersion.parse(v2));
}
}

@ -0,0 +1,44 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link BuildLog}.
*
* @author Phillip Webb
*/
class BuildLogTests {
@Test
void toSystemOutPrintsToSystemOut() {
BuildLog log = BuildLog.toSystemOut();
assertThat(log).isInstanceOf(PrintStreamBuildLog.class);
assertThat(log).extracting("out").isSameAs(System.out);
}
@Test
void toPrintsToOutput() {
BuildLog log = BuildLog.to(System.err);
assertThat(log).isInstanceOf(PrintStreamBuildLog.class);
assertThat(log).extracting("out").isSameAs(System.err);
}
}

@ -0,0 +1,86 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
/**
* Tests for {@link BuildOwner}.
*
* @author Phillip Webb
*/
class BuildOwnerTests {
@Test
void fromEnvReturnsOwner() {
Map<String, String> env = new LinkedHashMap<>();
env.put("CNB_USER_ID", "123");
env.put("CNB_GROUP_ID", "456");
BuildOwner owner = BuildOwner.fromEnv(env);
assertThat(owner.getUid()).isEqualTo(123);
assertThat(owner.getGid()).isEqualTo(456);
assertThat(owner.toString()).isEqualTo("123/456");
}
@Test
void fromEnvWhenEnvIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> BuildOwner.fromEnv(null))
.withMessage("Env must not be null");
}
@Test
void fromEnvWhenUserPropertyIsMissingThrowsException() {
Map<String, String> env = new LinkedHashMap<>();
env.put("CNB_GROUP_ID", "456");
assertThatIllegalStateException().isThrownBy(() -> BuildOwner.fromEnv(env))
.withMessage("Missing 'CNB_USER_ID' value from the builder environment");
}
@Test
void fromEnvWhenGroupPropertyIsMissingThrowsException() {
Map<String, String> env = new LinkedHashMap<>();
env.put("CNB_USER_ID", "123");
assertThatIllegalStateException().isThrownBy(() -> BuildOwner.fromEnv(env))
.withMessage("Missing 'CNB_GROUP_ID' value from the builder environment");
}
@Test
void fromEnvWhenUserPropertyIsMalformedThrowsException() {
Map<String, String> env = new LinkedHashMap<>();
env.put("CNB_USER_ID", "nope");
env.put("CNB_GROUP_ID", "456");
assertThatIllegalStateException().isThrownBy(() -> BuildOwner.fromEnv(env))
.withMessage("Malformed 'CNB_USER_ID' value 'nope' in the builder environment");
}
@Test
void fromEnvWhenGroupPropertyIsMalformedThrowsException() {
Map<String, String> env = new LinkedHashMap<>();
env.put("CNB_USER_ID", "123");
env.put("CNB_GROUP_ID", "nope");
assertThatIllegalStateException().isThrownBy(() -> BuildOwner.fromEnv(env))
.withMessage("Malformed 'CNB_GROUP_ID' value 'nope' in the builder environment");
}
}

@ -0,0 +1,167 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.cloudnativebuildpack.io.Owner;
import org.springframework.boot.cloudnativebuildpack.io.TarArchive;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.entry;
/**
* Tests for {@link BuildRequest}.
*
* @author Phillip Webb
*/
public class BuildRequestTests {
@TempDir
File tempDir;
@Test
void forJarFileReturnsRequest() throws IOException {
File jarFile = new File(this.tempDir, "my-app-0.0.1.jar");
writeTestJarFile(jarFile);
BuildRequest request = BuildRequest.forJarFile(jarFile);
assertThat(request.getName().toString()).isEqualTo("docker.io/library/my-app:0.0.1");
assertThat(request.getBuilder().toString()).isEqualTo("docker.io/cloudfoundry/cnb:0.0.43-bionic");
assertThat(request.getApplicationContent(Owner.ROOT)).satisfies(this::hasExpectedJarContent);
assertThat(request.getEnv()).isEmpty();
}
@Test
void forJarFileWithNameReturnsRequest() throws IOException {
File jarFile = new File(this.tempDir, "my-app-0.0.1.jar");
writeTestJarFile(jarFile);
BuildRequest request = BuildRequest.forJarFile(ImageReference.of("test-app"), jarFile);
assertThat(request.getName().toString()).isEqualTo("docker.io/library/test-app:latest");
assertThat(request.getBuilder().toString()).isEqualTo("docker.io/cloudfoundry/cnb:0.0.43-bionic");
assertThat(request.getApplicationContent(Owner.ROOT)).satisfies(this::hasExpectedJarContent);
assertThat(request.getEnv()).isEmpty();
}
@Test
void forJarFileWhenJarFileIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> BuildRequest.forJarFile(null))
.withMessage("JarFile must not be null");
}
@Test
void forJarFileWhenJarFileIsMissingThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> BuildRequest.forJarFile(new File(this.tempDir, "missing.jar")))
.withMessage("JarFile must exist");
}
@Test
void forJarFileWhenJarFileIsFolderThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> BuildRequest.forJarFile(this.tempDir))
.withMessage("JarFile must be a file");
}
@Test
void withBuilderUpdatesBuilder() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"))
.withBuilder(ImageReference.of("spring/builder"));
assertThat(request.getBuilder().toString()).isEqualTo("docker.io/spring/builder:latest");
}
@Test
void withEnvAddsEnvEntry() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
BuildRequest withEnv = request.withEnv("spring", "boot");
assertThat(request.getEnv()).isEmpty();
assertThat(withEnv.getEnv()).containsExactly(entry("spring", "boot"));
}
@Test
void withEnvMapAddsEnvEntries() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
Map<String, String> env = new LinkedHashMap<>();
env.put("spring", "boot");
env.put("test", "test");
BuildRequest withEnv = request.withEnv(env);
assertThat(request.getEnv()).isEmpty();
assertThat(withEnv.getEnv()).containsExactly(entry("spring", "boot"), entry("test", "test"));
}
@Test
void withEnvWhenKeyIsNullThrowsException() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
assertThatIllegalArgumentException().isThrownBy(() -> request.withEnv(null, "test"))
.withMessage("Name must not be empty");
}
@Test
void withEnvWhenValueIsNullThrowsException() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar"));
assertThatIllegalArgumentException().isThrownBy(() -> request.withEnv("test", null))
.withMessage("Value must not be empty");
}
private void hasExpectedJarContent(TarArchive archive) {
try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
archive.writeTo(outputStream);
try (TarArchiveInputStream tar = new TarArchiveInputStream(
new ByteArrayInputStream(outputStream.toByteArray()))) {
assertThat(tar.getNextEntry().getName()).isEqualTo("spring/");
assertThat(tar.getNextEntry().getName()).isEqualTo("spring/boot");
assertThat(tar.getNextEntry()).isNull();
}
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
private File writeTestJarFile(String name) throws IOException {
File file = new File(this.tempDir, name);
writeTestJarFile(file);
return file;
}
private void writeTestJarFile(File file) throws IOException {
try (ZipArchiveOutputStream zip = new ZipArchiveOutputStream(file)) {
ZipArchiveEntry dirEntry = new ZipArchiveEntry("spring/");
zip.putArchiveEntry(dirEntry);
zip.closeArchiveEntry();
ZipArchiveEntry fileEntry = new ZipArchiveEntry("spring/boot");
zip.putArchiveEntry(fileEntry);
zip.write("test".getBytes(StandardCharsets.UTF_8));
zip.closeArchiveEntry();
}
}
}

@ -0,0 +1,97 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageConfig;
import org.springframework.boot.cloudnativebuildpack.json.AbstractJsonTests;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link BuilderMetadata}.
*
* @author Phillip Webb
*/
class BuilderMetadataTests extends AbstractJsonTests {
@Test
void fromImageLoadsMetadata() throws IOException {
Image image = Image.of(getContent("image.json"));
BuilderMetadata metadata = BuilderMetadata.fromImage(image);
assertThat(metadata.getStack().getRunImage().getImage()).isEqualTo("cloudfoundry/run:full-cnb");
assertThat(metadata.getStack().getRunImage().getMirrors()).isEmpty();
assertThat(metadata.getLifecycle().getVersion()).isEqualTo("0.5.0");
assertThat(metadata.getLifecycle().getApi().getBuildpack()).isEqualTo("0.2");
assertThat(metadata.getLifecycle().getApi().getPlatform()).isEqualTo("0.1");
assertThat(metadata.getCreatedBy().getName()).isEqualTo("Pack CLI");
assertThat(metadata.getCreatedBy().getVersion())
.isEqualTo("v0.5.0 (git sha: c9cfac75b49609524e1ea33f809c12071406547c)");
}
@Test
void fromImageWhenImageIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> BuilderMetadata.fromImage(null))
.withMessage("Image must not be null");
}
@Test
void fromImageWhenImageConfigIsNullThrowsException() {
Image image = mock(Image.class);
assertThatIllegalArgumentException().isThrownBy(() -> BuilderMetadata.fromImage(image))
.withMessage("ImageConfig must not be null");
}
@Test
void fromImageConfigWhenLabelIsMissingThrowsException() {
Image image = mock(Image.class);
ImageConfig imageConfig = mock(ImageConfig.class);
given(image.getConfig()).willReturn(imageConfig);
assertThatIllegalArgumentException().isThrownBy(() -> BuilderMetadata.fromImage(image))
.withMessage("No 'io.buildpacks.builder.metadata' label found in image config");
}
@Test
void copyWithUpdatedCreatedByReturnsNewMetadata() throws IOException {
Image image = Image.of(getContent("image.json"));
BuilderMetadata metadata = BuilderMetadata.fromImage(image);
BuilderMetadata copy = metadata.copy((update) -> update.withCreatedBy("test123", "test456"));
assertThat(copy).isNotSameAs(metadata);
assertThat(copy.getCreatedBy().getName()).isEqualTo("test123");
assertThat(copy.getCreatedBy().getVersion()).isEqualTo("test456");
}
@Test
void attachToUpdatesMetadata() throws IOException {
Image image = Image.of(getContent("image.json"));
ImageConfig imageConfig = image.getConfig();
BuilderMetadata metadata = BuilderMetadata.fromImage(image);
ImageConfig imageConfigCopy = imageConfig.copy(metadata::attachTo);
String label = imageConfigCopy.getLabels().get("io.buildpacks.builder.metadata");
BuilderMetadata metadataCopy = BuilderMetadata.fromJson(label);
assertThat(metadataCopy.getStack().getRunImage().getImage())
.isEqualTo(metadata.getStack().getRunImage().getImage());
}
}

@ -0,0 +1,151 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.stubbing.Answer;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi.ContainerApi;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi.ImageApi;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi.VolumeApi;
import org.springframework.boot.cloudnativebuildpack.docker.TotalProgressPullListener;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageArchive;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.cloudnativebuildpack.io.TarArchive;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* Tests for {@link Builder}.
*
* @author Phillip Webb
*/
class BuilderTests {
@Test
void createWhenLogIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> new Builder(null)).withMessage("Log must not be null");
}
@Test
void buildWhenRequestIsNullThrowsException() {
Builder builder = new Builder();
assertThatIllegalArgumentException().isThrownBy(() -> builder.build(null))
.withMessage("Request must not be null");
}
@Test
void buildInvokesBuildpack() 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("docker.io/cloudfoundry/cnb:0.0.43-bionic")), any()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:full-cnb")), any()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
BuildRequest request = getTestRequest();
builder.build(request);
assertThat(out.toString()).contains("Running detector");
assertThat(out.toString()).contains("Running restorer");
assertThat(out.toString()).contains("Running analyzer");
assertThat(out.toString()).contains("Running builder");
assertThat(out.toString()).contains("Running exporter");
assertThat(out.toString()).contains("Running cacher");
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();
DockerApi docker = mockDockerApi();
Image builderImage = loadImage("image.json");
Image runImage = loadImage("run-image-with-bad-stack.json");
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/cnb:0.0.43-bionic")), any()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of("docker.io/cloudfoundry/run:full-cnb")), any()))
.willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
BuildRequest request = getTestRequest();
assertThatIllegalStateException().isThrownBy(() -> builder.build(request)).withMessage(
"Run image stack 'org.cloudfoundry.stacks.cfwindowsfs3' does not match builder stack 'org.cloudfoundry.stacks.cflinuxfs3'");
}
private DockerApi mockDockerApi() {
DockerApi docker = mock(DockerApi.class);
ImageApi imageApi = mock(ImageApi.class);
ContainerApi containerApi = mock(ContainerApi.class);
VolumeApi volumeApi = mock(VolumeApi.class);
given(docker.image()).willReturn(imageApi);
given(docker.container()).willReturn(containerApi);
given(docker.volume()).willReturn(volumeApi);
return docker;
}
private BuildRequest getTestRequest() {
TarArchive content = mock(TarArchive.class);
ImageReference name = ImageReference.of("my-application");
BuildRequest request = BuildRequest.of(name, (owner) -> content);
return request;
}
private Image loadImage(String name) throws IOException {
return Image.of(getClass().getResourceAsStream(name));
}
private Answer<Image> withPulledImage(Image image) {
return (invocation) -> {
TotalProgressPullListener listener = invocation.getArgument(1, TotalProgressPullListener.class);
listener.onStart();
listener.onFinish();
return image;
};
}
static class TestPrintStream extends PrintStream {
TestPrintStream() {
super(new ByteArrayOutputStream());
}
@Override
public String toString() {
return this.out.toString();
}
}
}

@ -0,0 +1,164 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Collections;
import java.util.Map;
import org.apache.commons.compress.archivers.ArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.utils.IOUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageArchive;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageConfig;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.cloudnativebuildpack.json.AbstractJsonTests;
import org.springframework.util.FileCopyUtils;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link EphemeralBuilder}.
*
* @author Phillip Webb
*/
class EphemeralBuilderTests extends AbstractJsonTests {
@TempDir
File temp;
private final BuildOwner owner = BuildOwner.of(123, 456);
private Image image;
private BuilderMetadata metadata;
private Map<String, String> env;
@BeforeEach
void setup() throws Exception {
this.image = Image.of(getContent("image.json"));
this.metadata = BuilderMetadata.fromImage(this.image);
this.env = Collections.singletonMap("spring", "boot");
}
@Test
void getNameHasRandomName() throws Exception {
EphemeralBuilder b1 = new EphemeralBuilder(this.owner, this.image, this.metadata, this.env);
EphemeralBuilder b2 = new EphemeralBuilder(this.owner, this.image, this.metadata, this.env);
assertThat(b1.getName().toString()).startsWith("pack.local/builder/").endsWith(":latest");
assertThat(b1.getName().toString()).isNotEqualTo(b2.getName().toString());
}
@Test
void getArchiveHasCreatedByConfig() throws Exception {
EphemeralBuilder builder = new EphemeralBuilder(this.owner, this.image, this.metadata, this.env);
ImageConfig config = builder.getArchive().getImageConfig();
BuilderMetadata ephemeralMetadata = BuilderMetadata.fromImageConfig(config);
assertThat(ephemeralMetadata.getCreatedBy().getName()).isEqualTo("Spring Boot");
}
@Test
void getArchiveHasTag() throws Exception {
EphemeralBuilder builder = new EphemeralBuilder(this.owner, this.image, this.metadata, this.env);
ImageReference tag = builder.getArchive().getTag();
assertThat(tag.toString()).startsWith("pack.local/builder/").endsWith(":latest");
}
@Test
void getArchiveHasCreateDate() throws Exception {
Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC);
EphemeralBuilder builder = new EphemeralBuilder(clock, this.owner, this.image, this.metadata, this.env);
assertThat(builder.getArchive().getCreateDate()).isEqualTo(Instant.now(clock));
}
@Test
void getArchiveContainsDefaultDirsLayer() throws Exception {
EphemeralBuilder builder = new EphemeralBuilder(this.owner, this.image, this.metadata, this.env);
File folder = unpack(getLayer(builder.getArchive(), 0), "dirs");
assertThat(new File(folder, "workspace")).isDirectory();
assertThat(new File(folder, "layers")).isDirectory();
assertThat(new File(folder, "cnb")).isDirectory();
assertThat(new File(folder, "cnb/buildpacks")).isDirectory();
assertThat(new File(folder, "platform")).isDirectory();
assertThat(new File(folder, "platform/env")).isDirectory();
}
@Test
void getArchiveContainsStackLayer() throws Exception {
EphemeralBuilder builder = new EphemeralBuilder(this.owner, this.image, this.metadata, this.env);
File folder = unpack(getLayer(builder.getArchive(), 1), "stack");
File tomlFile = new File(folder, "cnb/stack.toml");
assertThat(tomlFile).exists();
String toml = FileCopyUtils
.copyToString(new InputStreamReader(new FileInputStream(tomlFile), StandardCharsets.UTF_8));
assertThat(toml).contains("[run-image]").contains("image = ");
}
@Test
void getArchiveContainsEnvLayer() throws Exception {
EphemeralBuilder builder = new EphemeralBuilder(this.owner, this.image, this.metadata, this.env);
File folder = unpack(getLayer(builder.getArchive(), 2), "env");
assertThat(new File(folder, "platform/env/spring")).usingCharset(StandardCharsets.UTF_8).hasContent("boot");
}
private TarArchiveInputStream getLayer(ImageArchive archive, int index) throws Exception {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
archive.writeTo(outputStream);
TarArchiveInputStream tar = new TarArchiveInputStream(new ByteArrayInputStream(outputStream.toByteArray()));
for (int i = 0; i <= index; i++) {
tar.getNextEntry();
}
return new TarArchiveInputStream(tar);
}
private File unpack(TarArchiveInputStream archive, String name) throws Exception {
File folder = new File(this.temp, name);
folder.mkdirs();
ArchiveEntry entry = archive.getNextEntry();
while (entry != null) {
File file = new File(folder, entry.getName());
if (entry.isDirectory()) {
file.mkdirs();
}
else {
file.getParentFile().mkdirs();
try (OutputStream out = new FileOutputStream(file)) {
IOUtils.copy(archive, out);
}
}
entry = archive.getNextEntry();
}
return folder;
}
}

@ -0,0 +1,225 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.stubbing.Answer;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi.ContainerApi;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi.ImageApi;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi.VolumeApi;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerConfig;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerContent;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerReference;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.cloudnativebuildpack.docker.type.VolumeName;
import org.springframework.boot.cloudnativebuildpack.io.IOConsumer;
import org.springframework.boot.cloudnativebuildpack.io.TarArchive;
import org.springframework.boot.cloudnativebuildpack.json.SharedObjectMapper;
import org.springframework.util.FileCopyUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* Tests for {@link Lifecycle}.
*
* @author Phillip Webb
*/
class LifecycleTests {
private TestPrintStream out;
private DockerApi docker;
private Lifecycle lifecycle;
private Map<String, ContainerConfig> configs = new LinkedHashMap<>();
private Map<String, ContainerContent> content = new LinkedHashMap<>();
@BeforeEach
void setup() throws Exception {
this.out = new TestPrintStream();
this.docker = mockDockerApi();
BuildRequest request = getTestRequest();
this.lifecycle = createLifecycle(request);
}
private DockerApi mockDockerApi() {
DockerApi docker = mock(DockerApi.class);
ImageApi imageApi = mock(ImageApi.class);
ContainerApi containerApi = mock(ContainerApi.class);
VolumeApi volumeApi = mock(VolumeApi.class);
given(docker.image()).willReturn(imageApi);
given(docker.container()).willReturn(containerApi);
given(docker.volume()).willReturn(volumeApi);
return docker;
}
@Test
void executeExecutesPhases() throws Exception {
given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId());
given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId());
this.lifecycle.execute();
assertPhaseWasRun("detector", withExpectedConfig("lifecycle-detector.json"));
assertPhaseWasRun("restorer", withExpectedConfig("lifecycle-restorer.json"));
assertPhaseWasRun("analyzer", withExpectedConfig("lifecycle-analyzer.json"));
assertPhaseWasRun("builder", withExpectedConfig("lifecycle-builder.json"));
assertPhaseWasRun("exporter", withExpectedConfig("lifecycle-exporter.json"));
assertPhaseWasRun("cacher", withExpectedConfig("lifecycle-cacher.json"));
assertThat(this.out.toString()).contains("Successfully built image 'docker.io/library/my-application:latest'");
}
@Test
void executeOnlyUploadsContentOnce() throws Exception {
given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId());
given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId());
this.lifecycle.execute();
assertThat(this.content).hasSize(1);
}
@Test
void executeWhenAleadyRunThrowsException() throws Exception {
given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId());
given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId());
this.lifecycle.execute();
assertThatIllegalStateException().isThrownBy(this.lifecycle::execute)
.withMessage("Lifecycle has already been executed");
}
@Test
void executeWhenCleanCacheClearsCache() throws Exception {
given(this.docker.container().create(any())).willAnswer(answerWithGeneratedContainerId());
given(this.docker.container().create(any(), any())).willAnswer(answerWithGeneratedContainerId());
BuildRequest request = getTestRequest().withCleanCache(true);
createLifecycle(request).execute();
VolumeName name = VolumeName.of("pack-cache-b35197ac41ea.build");
verify(this.docker.volume()).delete(name, true);
}
@Test
void closeClearsVolumes() throws Exception {
this.lifecycle.close();
verify(this.docker.volume()).delete(VolumeName.of("pack-layers-aaaaaaaaaa"), true);
verify(this.docker.volume()).delete(VolumeName.of("pack-app-aaaaaaaaaa"), true);
}
private BuildRequest getTestRequest() {
TarArchive content = mock(TarArchive.class);
ImageReference name = ImageReference.of("my-application");
BuildRequest request = BuildRequest.of(name, (owner) -> content);
return request;
}
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);
}
private EphemeralBuilder mockEphemeralBuilder() throws IOException {
EphemeralBuilder builder = mock(EphemeralBuilder.class);
byte[] metadataContent = FileCopyUtils.copyToByteArray(getClass().getResourceAsStream("builder-metadata.json"));
BuilderMetadata metadata = BuilderMetadata.fromJson(new String(metadataContent, StandardCharsets.UTF_8));
given(builder.getName()).willReturn(ImageReference.of("pack.local/ephemeral-builder"));
given(builder.getBuilderMetadata()).willReturn(metadata);
return builder;
}
private Answer<ContainerReference> answerWithGeneratedContainerId() {
return (invocation) -> {
ContainerConfig config = invocation.getArgument(0, ContainerConfig.class);
ArrayNode command = getCommand(config);
String name = command.get(0).asText().substring(1).replaceAll("/", "-");
this.configs.put(name, config);
if (invocation.getArguments().length > 1) {
this.content.put(name, invocation.getArgument(1, ContainerContent.class));
}
return ContainerReference.of(name);
};
}
private ArrayNode getCommand(ContainerConfig config) throws JsonProcessingException, JsonMappingException {
JsonNode node = SharedObjectMapper.get().readTree(config.toString());
return (ArrayNode) node.at("/Cmd");
}
private void assertPhaseWasRun(String name, IOConsumer<ContainerConfig> configConsumer) throws IOException {
ContainerReference containerReference = ContainerReference.of("lifecycle-" + name);
verify(this.docker.container()).start(containerReference);
verify(this.docker.container()).logs(eq(containerReference), any());
verify(this.docker.container()).remove(containerReference, true);
configConsumer.accept(this.configs.get(containerReference.toString()));
}
private IOConsumer<ContainerConfig> withExpectedConfig(String name) {
return (config) -> {
InputStream in = getClass().getResourceAsStream(name);
String json = FileCopyUtils.copyToString(new InputStreamReader(in, StandardCharsets.UTF_8));
assertThat(config.toString()).isEqualToIgnoringWhitespace(json);
};
}
static class TestLifecycle extends Lifecycle {
TestLifecycle(BuildLog log, DockerApi docker, BuildRequest request, ImageReference runImageReferece,
EphemeralBuilder builder) {
super(log, docker, request, runImageReferece, builder);
}
@Override
protected VolumeName createRandomVolumeName(String prefix) {
return VolumeName.of(prefix + "aaaaaaaaaa");
}
}
static class TestPrintStream extends PrintStream {
TestPrintStream() {
super(new ByteArrayOutputStream());
}
@Override
public String toString() {
return this.out.toString();
}
}
}

@ -0,0 +1,72 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link LifecycleVersion}.
*
* @author Phillip Webb
*/
class LifecycleVersionTests {
@Test
void parseWhenValueIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> LifecycleVersion.parse(null))
.withMessage("Value must not be empty");
}
@Test
void parseWhenTooLongThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> LifecycleVersion.parse("v1.2.3.4"))
.withMessage("Malformed version number '1.2.3.4'");
}
@Test
void parseWhenNonNumericThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> LifecycleVersion.parse("v1.2.3a"))
.withMessage("Malformed version number '1.2.3a'");
}
@Test
void compareTo() {
LifecycleVersion v4 = LifecycleVersion.parse("0.0.4");
assertThat(LifecycleVersion.parse("0.0.3").compareTo(v4)).isNegative();
assertThat(LifecycleVersion.parse("0.0.4").compareTo(v4)).isZero();
assertThat(LifecycleVersion.parse("0.0.5").compareTo(v4)).isPositive();
}
@Test
void isEqualOrGreaterThan() {
LifecycleVersion v4 = LifecycleVersion.parse("0.0.4");
assertThat(LifecycleVersion.parse("0.0.3").isEqualOrGreaterThan(v4)).isFalse();
assertThat(LifecycleVersion.parse("0.0.4").isEqualOrGreaterThan(v4)).isTrue();
assertThat(LifecycleVersion.parse("0.0.5").isEqualOrGreaterThan(v4)).isTrue();
}
@Test
void parseReturnsVersion() {
assertThat(LifecycleVersion.parse("1.2.3").toString()).isEqualTo("v1.2.3");
assertThat(LifecycleVersion.parse("1.2").toString()).isEqualTo("v1.2.0");
assertThat(LifecycleVersion.parse("1").toString()).isEqualTo("v1.0.0");
}
}

@ -0,0 +1,119 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerConfig.Update;
import org.springframework.boot.cloudnativebuildpack.docker.type.VolumeName;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
/**
* Tests for {@link Phase}.
*
* @author Phillip Webb
*/
class PhaseTests {
private static final String[] NO_ARGS = {};
@Test
void getNameReturnsName() {
Phase phase = new Phase("test", false);
assertThat(phase.getName()).isEqualTo("test");
}
@Test
void toStringReturnsName() {
Phase phase = new Phase("test", false);
assertThat(phase).hasToString("test");
}
@Test
void applyUpdatesConfiguration() {
Phase phase = new Phase("test", false);
Update update = mock(Update.class);
phase.apply(update);
verify(update).withCommand("/lifecycle/test", NO_ARGS);
verify(update).withLabel("author", "spring-boot");
verifyNoMoreInteractions(update);
}
@Test
void applyWhenWithDaemonAccessUpdatesConfigurationWithRootUserAndDomainSocketBinding() {
Phase phase = new Phase("test", false);
phase.withDaemonAccess();
Update update = mock(Update.class);
phase.apply(update);
verify(update).withUser("root");
verify(update).withBind("/var/run/docker.sock", "/var/run/docker.sock");
verify(update).withCommand("/lifecycle/test", NO_ARGS);
verify(update).withLabel("author", "spring-boot");
verifyNoMoreInteractions(update);
}
@Test
void applyWhenWithLogLevelArgAndVerboseLoggingUpdatesConfigurationWithLogLevel() {
Phase phase = new Phase("test", true);
phase.withLogLevelArg();
Update update = mock(Update.class);
phase.apply(update);
verify(update).withCommand("/lifecycle/test", "-log-level", "debug");
verify(update).withLabel("author", "spring-boot");
verifyNoMoreInteractions(update);
}
@Test
void applyWhenWithLogLevelArgAndNonVerboseLoggingDoesNotUpdateLogLevel() {
Phase phase = new Phase("test", false);
phase.withLogLevelArg();
Update update = mock(Update.class);
phase.apply(update);
verify(update).withCommand("/lifecycle/test");
verify(update).withLabel("author", "spring-boot");
verifyNoMoreInteractions(update);
}
@Test
void applyWhenWithArgsUpdatesConfigurationWithArguments() {
Phase phase = new Phase("test", false);
phase.withArgs("a", "b", "c");
Update update = mock(Update.class);
phase.apply(update);
verify(update).withCommand("/lifecycle/test", "a", "b", "c");
verify(update).withLabel("author", "spring-boot");
verifyNoMoreInteractions(update);
}
@Test
void applyWhenWithBindsUpdatesConfigurationWithBinds() {
Phase phase = new Phase("test", false);
VolumeName volumeName = VolumeName.of("test");
phase.withBinds(volumeName, "/test");
Update update = mock(Update.class);
phase.apply(update);
verify(update).withCommand("/lifecycle/test");
verify(update).withLabel("author", "spring-boot");
verify(update).withBind(volumeName, "/test");
verifyNoMoreInteractions(update);
}
}

@ -0,0 +1,99 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.io.ByteArrayOutputStream;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.function.Consumer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudnativebuildpack.docker.LogUpdateEvent;
import org.springframework.boot.cloudnativebuildpack.docker.TotalProgressEvent;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.cloudnativebuildpack.docker.type.VolumeName;
import org.springframework.util.FileCopyUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link PrintStreamBuildLog}.
*
* @author Phillip Webb
*/
class PrintStreamBuildLogTests {
@Test
void printsExpectedOutput() throws Exception {
TestPrintStream out = new TestPrintStream();
PrintStreamBuildLog log = new PrintStreamBuildLog(out);
BuildRequest request = mock(BuildRequest.class);
ImageReference name = ImageReference.of("my-app:latest");
ImageReference builderImageReference = ImageReference.of("cnb/builder");
Image builderImage = mock(Image.class);
given(builderImage.getDigests()).willReturn(Collections.singletonList("00000001"));
ImageReference runImageReference = ImageReference.of("cnb/runner");
Image runImage = mock(Image.class);
given(runImage.getDigests()).willReturn(Collections.singletonList("00000002"));
given(request.getName()).willReturn(name);
log.start(request);
Consumer<TotalProgressEvent> pullBuildImageConsumer = log.pullingBuilder(request, builderImageReference);
pullBuildImageConsumer.accept(new TotalProgressEvent(100));
log.pulledBulder(request, builderImage);
Consumer<TotalProgressEvent> pullRunImageConsumer = log.pullingRunImage(request, runImageReference);
pullRunImageConsumer.accept(new TotalProgressEvent(100));
log.pulledRunImage(request, runImage);
log.executingLifecycle(request, LifecycleVersion.parse("0.5"), VolumeName.of("pack-abc.cache"));
Consumer<LogUpdateEvent> phase1Consumer = log.runningPhase(request, "alphabet");
phase1Consumer.accept(mockLogEvent("one"));
phase1Consumer.accept(mockLogEvent("two"));
phase1Consumer.accept(mockLogEvent("three"));
Consumer<LogUpdateEvent> phase2Consumer = log.runningPhase(request, "basket");
phase2Consumer.accept(mockLogEvent("spring"));
phase2Consumer.accept(mockLogEvent("boot"));
log.executedLifecycle(request);
String expected = FileCopyUtils.copyToString(new InputStreamReader(
getClass().getResourceAsStream("print-stream-build-log.txt"), StandardCharsets.UTF_8));
assertThat(out.toString()).isEqualToIgnoringNewLines(expected);
}
private LogUpdateEvent mockLogEvent(String string) {
LogUpdateEvent event = mock(LogUpdateEvent.class);
given(event.toString()).willReturn(string);
return event;
}
static class TestPrintStream extends PrintStream {
TestPrintStream() {
super(new ByteArrayOutputStream());
}
@Override
public String toString() {
return this.out.toString();
}
}
}

@ -0,0 +1,85 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.build;
import java.util.Collections;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link StackId}.
*
* @author Phillip Webb
*/
class StackIdTests {
@Test
void fromImageWhenImageIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> StackId.fromImage(null))
.withMessage("Image must not be null");
}
@Test
void fromImageWhenLabelIsMissingThrowsException() {
Image image = mock(Image.class);
ImageConfig imageConfig = mock(ImageConfig.class);
given(image.getConfig()).willReturn(imageConfig);
assertThatIllegalStateException().isThrownBy(() -> StackId.fromImage(image))
.withMessage("Missing 'io.buildpacks.stack.id' stack label");
}
@Test
void fromImageCreatesStackId() {
Image image = mock(Image.class);
ImageConfig imageConfig = mock(ImageConfig.class);
given(image.getConfig()).willReturn(imageConfig);
given(imageConfig.getLabels()).willReturn(Collections.singletonMap("io.buildpacks.stack.id", "test"));
StackId stackId = StackId.fromImage(image);
assertThat(stackId.toString()).isEqualTo("test");
}
@Test
void ofCreatesStackId() {
StackId stackId = StackId.of("test");
assertThat(stackId.toString()).isEqualTo("test");
}
@Test
void equalsAndHashCode() {
StackId s1 = StackId.of("a");
StackId s2 = StackId.of("a");
StackId s3 = StackId.of("b");
assertThat(s1.hashCode()).isEqualTo(s2.hashCode());
assertThat(s1).isEqualTo(s1).isEqualTo(s2).isNotEqualTo(s3);
}
@Test
void toStringReturnsValue() {
StackId stackId = StackId.of("test");
assertThat(stackId.toString()).isEqualTo("test");
}
}

@ -0,0 +1,42 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable;
/**
* Integration tests for {@link DockerApi}.
*
* @author Phillip Webb
*/
@DisabledIfDockerUnavailable
class DockerApiIntegrationTests {
private final DockerApi docker = new DockerApi();
@Test
void pullImage() throws IOException {
this.docker.image().pull(ImageReference.of("cloudfoundry/cnb:bionic"),
new TotalProgressPullListener(new TotalProgressBar("Pulling: ")));
}
}

@ -0,0 +1,393 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi.ContainerApi;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi.ImageApi;
import org.springframework.boot.cloudnativebuildpack.docker.DockerApi.VolumeApi;
import org.springframework.boot.cloudnativebuildpack.docker.Http.Response;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerConfig;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerContent;
import org.springframework.boot.cloudnativebuildpack.docker.type.ContainerReference;
import org.springframework.boot.cloudnativebuildpack.docker.type.Image;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageArchive;
import org.springframework.boot.cloudnativebuildpack.docker.type.ImageReference;
import org.springframework.boot.cloudnativebuildpack.docker.type.VolumeName;
import org.springframework.boot.cloudnativebuildpack.io.Content;
import org.springframework.boot.cloudnativebuildpack.io.IOConsumer;
import org.springframework.boot.cloudnativebuildpack.io.Owner;
import org.springframework.boot.cloudnativebuildpack.io.TarArchive;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* Tests for {@link DockerApi}.
*
* @author Phillip Webb
*/
class DockerApiTests {
private static final String API_URL = "docker://localhost/v1.40";
private static final String IMAGES_URL = API_URL + "/images";
private static final String CONTAINERS_URL = API_URL + "/containers";
private static final String VOLUMES_URL = API_URL + "/volumes";
@Mock
private HttpClientHttp httpClient;
private DockerApi dockerApi;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
this.dockerApi = new DockerApi(this.httpClient);
}
private HttpClientHttp httpClient() {
return this.httpClient;
}
private Response emptyResponse() throws IOException {
return responseOf(null);
}
private Response responseOf(String name) throws IOException {
return new Response() {
@Override
public void close() throws IOException {
}
@Override
public InputStream getContent() throws IOException {
if (name == null) {
return null;
}
return getClass().getResourceAsStream(name);
}
};
}
@Nested
class ImageDockerApiTests {
private ImageApi api;
@Mock
private UpdateListener<PullImageUpdateEvent> pullListener;
@Mock
private UpdateListener<LoadImageUpdateEvent> loadListener;
@Captor
private ArgumentCaptor<IOConsumer<OutputStream>> writer;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
this.api = DockerApiTests.this.dockerApi.image();
}
@Test
void pullWhenReferenceIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.pull(null, this.pullListener))
.withMessage("Reference must not be null");
}
@Test
void pullWhenListenerIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.pull(ImageReference.of("ubuntu"), null))
.withMessage("Listener must not be null");
}
@Test
void pullPullsImageAndProducesEvents() throws Exception {
ImageReference reference = ImageReference.of("cloudfoundry/cnb:bionic");
URI createUri = new URI(IMAGES_URL + "/create?fromImage=docker.io%2Fcloudfoundry%2Fcnb%3Abionic");
String imageHash = "4acb6bfd6c4f0cabaf7f3690e444afe51f1c7de54d51da7e63fac709c56f1c30";
URI imageUri = new URI(IMAGES_URL + "/docker.io/cloudfoundry/cnb@sha256:" + imageHash + "/json");
given(httpClient().post(createUri)).willReturn(responseOf("pull-stream.json"));
given(httpClient().get(imageUri)).willReturn(responseOf("type/image.json"));
Image image = this.api.pull(reference, this.pullListener);
assertThat(image.getLayers()).hasSize(46);
InOrder ordered = inOrder(this.pullListener);
ordered.verify(this.pullListener).onStart();
ordered.verify(this.pullListener, times(595)).onUpdate(any());
ordered.verify(this.pullListener).onFinish();
}
@Test
void loadWhenArchiveIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.load(null, UpdateListener.none()))
.withMessage("Archive must not be null");
}
@Test
void loadWhenListenerIsNullThrowsException() {
ImageArchive archive = mock(ImageArchive.class);
assertThatIllegalArgumentException().isThrownBy(() -> this.api.load(archive, null))
.withMessage("Listener must not be null");
}
@Test
void loadLoadsImage() throws Exception {
Image image = Image.of(getClass().getResourceAsStream("type/image.json"));
ImageArchive archive = ImageArchive.from(image);
URI loadUri = new URI(IMAGES_URL + "/load");
given(httpClient().post(eq(loadUri), eq("application/x-tar"), any()))
.willReturn(responseOf("load-stream.json"));
this.api.load(archive, this.loadListener);
InOrder ordered = inOrder(this.loadListener);
ordered.verify(this.loadListener).onStart();
ordered.verify(this.loadListener).onUpdate(any());
ordered.verify(this.loadListener).onFinish();
verify(httpClient()).post(any(), any(), this.writer.capture());
ByteArrayOutputStream out = new ByteArrayOutputStream();
this.writer.getValue().accept(out);
assertThat(out.toByteArray()).hasSizeGreaterThan(21000);
}
@Test
void removeWhenReferenceIsNulllThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.remove(null, true))
.withMessage("Reference must not be null");
}
@Test
void removeRemovesContainer() throws Exception {
ImageReference reference = ImageReference
.of("ubuntu@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
URI removeUri = new URI(IMAGES_URL
+ "/docker.io/library/ubuntu@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
given(httpClient().delete(removeUri)).willReturn(emptyResponse());
this.api.remove(reference, false);
verify(httpClient()).delete(removeUri);
}
@Test
void removeWhenForceIsTrueRemovesContainer() throws Exception {
ImageReference reference = ImageReference
.of("ubuntu@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
URI removeUri = new URI(IMAGES_URL
+ "/docker.io/library/ubuntu@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d?force=1");
given(httpClient().delete(removeUri)).willReturn(emptyResponse());
this.api.remove(reference, true);
verify(httpClient()).delete(removeUri);
}
}
@Nested
class ContainerDockerApiTests {
private ContainerApi api;
@Captor
private ArgumentCaptor<IOConsumer<OutputStream>> writer;
@Mock
private UpdateListener<LogUpdateEvent> logListener;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
this.api = DockerApiTests.this.dockerApi.container();
}
@Test
void createWhenConfigIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.create(null))
.withMessage("Config must not be null");
}
@Test
void createCreatesContainer() throws Exception {
ImageReference imageReference = ImageReference.of("ubuntu:bionic");
ContainerConfig config = ContainerConfig.of(imageReference, (update) -> update.withCommand("/bin/bash"));
URI createUri = new URI(CONTAINERS_URL + "/create");
given(httpClient().post(eq(createUri), eq("application/json"), any()))
.willReturn(responseOf("create-container-response.json"));
ContainerReference containerReference = this.api.create(config);
assertThat(containerReference.toString()).isEqualTo("e90e34656806");
ByteArrayOutputStream out = new ByteArrayOutputStream();
verify(httpClient()).post(any(), any(), this.writer.capture());
this.writer.getValue().accept(out);
assertThat(out.toByteArray()).hasSizeGreaterThan(130);
}
@Test
void createWhenHasContentContainerWithContent() throws Exception {
ImageReference imageReference = ImageReference.of("ubuntu:bionic");
ContainerConfig config = ContainerConfig.of(imageReference, (update) -> update.withCommand("/bin/bash"));
TarArchive archive = TarArchive.of((layout) -> {
layout.folder("/test", Owner.ROOT);
layout.file("/test/file", Owner.ROOT, Content.of("test"));
});
ContainerContent content = ContainerContent.of(archive);
URI createUri = new URI(CONTAINERS_URL + "/create");
given(httpClient().post(eq(createUri), eq("application/json"), any()))
.willReturn(responseOf("create-container-response.json"));
URI uploadUri = new URI(CONTAINERS_URL + "/e90e34656806/archive?path=%2F");
given(httpClient().put(eq(uploadUri), eq("application/x-tar"), any())).willReturn(emptyResponse());
ContainerReference containerReference = this.api.create(config, content);
assertThat(containerReference.toString()).isEqualTo("e90e34656806");
ByteArrayOutputStream out = new ByteArrayOutputStream();
verify(httpClient()).post(any(), any(), this.writer.capture());
this.writer.getValue().accept(out);
assertThat(out.toByteArray()).hasSizeGreaterThan(130);
verify(httpClient()).put(any(), any(), this.writer.capture());
this.writer.getValue().accept(out);
assertThat(out.toByteArray()).hasSizeGreaterThan(2000);
}
@Test
void startWhenReferenceIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.start(null))
.withMessage("Reference must not be null");
}
@Test
void startStartsContainer() throws Exception {
ContainerReference reference = ContainerReference.of("e90e34656806");
URI startContainerUri = new URI(CONTAINERS_URL + "/e90e34656806/start");
given(httpClient().post(startContainerUri)).willReturn(emptyResponse());
this.api.start(reference);
verify(httpClient()).post(startContainerUri);
}
@Test
void logsWhenReferenceIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.logs(null, UpdateListener.none()))
.withMessage("Reference must not be null");
}
@Test
void logsWhenListenerIsNullThrowsException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> this.api.logs(ContainerReference.of("e90e34656806"), null))
.withMessage("Listener must not be null");
}
@Test
void logsProducesEvents() throws Exception {
ContainerReference reference = ContainerReference.of("e90e34656806");
URI logsUri = new URI(CONTAINERS_URL + "/e90e34656806/logs?stdout=1&stderr=1&follow=1");
given(httpClient().get(logsUri)).willReturn(responseOf("log-update-event.stream"));
this.api.logs(reference, this.logListener);
InOrder ordered = inOrder(this.logListener);
ordered.verify(this.logListener).onStart();
ordered.verify(this.logListener, times(7)).onUpdate(any());
ordered.verify(this.logListener).onFinish();
}
@Test
void removeWhenReferenceIsNulllThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.remove(null, true))
.withMessage("Reference must not be null");
}
@Test
void removeRemovesContainer() throws Exception {
ContainerReference reference = ContainerReference.of("e90e34656806");
URI removeUri = new URI(CONTAINERS_URL + "/e90e34656806");
given(httpClient().delete(removeUri)).willReturn(emptyResponse());
this.api.remove(reference, false);
verify(httpClient()).delete(removeUri);
}
@Test
void removeWhenForceIsTrueRemovesContainer() throws Exception {
ContainerReference reference = ContainerReference.of("e90e34656806");
URI removeUri = new URI(CONTAINERS_URL + "/e90e34656806?force=1");
given(httpClient().delete(removeUri)).willReturn(emptyResponse());
this.api.remove(reference, true);
verify(httpClient()).delete(removeUri);
}
}
@Nested
class VolumeDockerApiTests {
private VolumeApi api;
@Captor
private ArgumentCaptor<IOConsumer<OutputStream>> writer;
@Mock
private UpdateListener<LogUpdateEvent> logListener;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
this.api = DockerApiTests.this.dockerApi.volume();
}
@Test
void deleteWhenNameIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.api.delete(null, false))
.withMessage("Name must not be null");
}
@Test
void deleteDeletesContainer() throws Exception {
VolumeName name = VolumeName.of("test");
URI removeUri = new URI(VOLUMES_URL + "/test");
given(httpClient().delete(removeUri)).willReturn(emptyResponse());
this.api.delete(name, false);
verify(httpClient()).delete(removeUri);
}
@Test
void deleteWhenForceIsTrueDeletesContainer() throws Exception {
VolumeName name = VolumeName.of("test");
URI removeUri = new URI(VOLUMES_URL + "/test?force=1");
given(httpClient().delete(removeUri)).willReturn(emptyResponse());
this.api.delete(name, true);
verify(httpClient()).delete(removeUri);
}
}
}

@ -0,0 +1,92 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collections;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link DockerException}.
*
* @author Phillip Webb
*/
class DockerExceptionTests {
private static final URI URI;
static {
try {
URI = new URI("docker://localhost");
}
catch (URISyntaxException ex) {
throw new IllegalStateException(ex);
}
}
private static final Errors NO_ERRORS = new Errors(Collections.emptyList());
private static final Errors ERRORS = new Errors(Collections.singletonList(new Errors.Error("code", "message")));
@Test
void createWhenUriIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> new DockerException(null, 404, null, NO_ERRORS))
.withMessage("URI must not be null");
}
@Test
void create() {
DockerException exception = new DockerException(URI, 404, "missing", ERRORS);
assertThat(exception.getMessage()).isEqualTo(
"Docker API call to 'docker://localhost' failed with status code 404 \"missing\" [code: message]");
assertThat(exception.getStatusCode()).isEqualTo(404);
assertThat(exception.getReasonPhrase()).isEqualTo("missing");
assertThat(exception.getErrors()).isSameAs(ERRORS);
}
@Test
void createWhenReasonPhraseIsNull() {
DockerException exception = new DockerException(URI, 404, null, ERRORS);
assertThat(exception.getMessage())
.isEqualTo("Docker API call to 'docker://localhost' failed with status code 404 [code: message]");
assertThat(exception.getStatusCode()).isEqualTo(404);
assertThat(exception.getReasonPhrase()).isNull();
assertThat(exception.getErrors()).isSameAs(ERRORS);
}
@Test
void createWhenErrorsIsNull() {
DockerException exception = new DockerException(URI, 404, "missing", null);
assertThat(exception.getErrors()).isNull();
}
@Test
void createWhenErrorsIsEmpty() {
DockerException exception = new DockerException(URI, 404, "missing", NO_ERRORS);
assertThat(exception.getMessage())
.isEqualTo("Docker API call to 'docker://localhost' failed with status code 404 \"missing\"");
assertThat(exception.getStatusCode()).isEqualTo(404);
assertThat(exception.getReasonPhrase()).isEqualTo("missing");
assertThat(exception.getErrors()).isSameAs(NO_ERRORS);
}
}

@ -0,0 +1,54 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.util.Iterator;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudnativebuildpack.docker.Errors.Error;
import org.springframework.boot.cloudnativebuildpack.json.AbstractJsonTests;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link Errors}.
*
* @author Phillip Webb
*/
class ErrorsTests extends AbstractJsonTests {
@Test
void readValueDeserializesJson() throws Exception {
Errors errors = this.getObjectMapper().readValue(getContent("errors.json"), Errors.class);
Iterator<Error> iterator = errors.iterator();
Error error1 = iterator.next();
Error error2 = iterator.next();
assertThat(iterator.hasNext()).isFalse();
assertThat(error1.getCode()).isEqualTo("TEST1");
assertThat(error1.getMessage()).isEqualTo("Test One");
assertThat(error2.getCode()).isEqualTo("TEST2");
assertThat(error2.getMessage()).isEqualTo("Test Two");
}
@Test
void toStringHasErrorDetails() throws Exception {
Errors errors = this.getObjectMapper().readValue(getContent("errors.json"), Errors.class);
assertThat(errors.toString()).isEqualTo("[TEST1: Test One, TEST2: Test Two]");
}
}

@ -0,0 +1,194 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpHeaders;
import org.apache.http.StatusLine;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.impl.client.CloseableHttpClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.boot.cloudnativebuildpack.docker.Http.Response;
import org.springframework.util.StreamUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
/**
* Tests for {@link HttpClientHttp}.
*
* @author Phillip Webb
*/
class HttpClientHttpTests {
private static final String APPLICATION_JSON = "application/json";
@Mock
private CloseableHttpClient client;
@Mock
private CloseableHttpResponse response;
@Mock
private StatusLine statusLine;
@Mock
private HttpEntity entity;
@Mock
private InputStream content;
@Captor
private ArgumentCaptor<HttpUriRequest> requestCaptor;
private HttpClientHttp http;
private URI uri;
@BeforeEach
void setup() throws Exception {
MockitoAnnotations.initMocks(this);
given(this.client.execute(any())).willReturn(this.response);
given(this.response.getEntity()).willReturn(this.entity);
given(this.response.getStatusLine()).willReturn(this.statusLine);
this.http = new HttpClientHttp(this.client);
this.uri = new URI("docker://localhost/example");
}
@Test
void getShouldExecuteHttpGet() throws Exception {
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.get(this.uri);
verify(this.client).execute(this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
assertThat(request).isInstanceOf(HttpGet.class);
assertThat(request.getURI()).isEqualTo(this.uri);
assertThat(request.getFirstHeader(HttpHeaders.CONTENT_TYPE)).isNull();
assertThat(response.getContent()).isSameAs(this.content);
}
@Test
void postShouldExecuteHttpPost() throws Exception {
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.post(this.uri);
verify(this.client).execute(this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
assertThat(request).isInstanceOf(HttpPost.class);
assertThat(request.getURI()).isEqualTo(this.uri);
assertThat(request.getFirstHeader(HttpHeaders.CONTENT_TYPE)).isNull();
assertThat(response.getContent()).isSameAs(this.content);
}
@Test
void postWithContentShouldExecuteHttpPost() throws Exception {
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.post(this.uri, APPLICATION_JSON,
(out) -> StreamUtils.copy("test", StandardCharsets.UTF_8, out));
verify(this.client).execute(this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
HttpEntity entity = ((HttpEntityEnclosingRequest) request).getEntity();
assertThat(request).isInstanceOf(HttpPost.class);
assertThat(request.getURI()).isEqualTo(this.uri);
assertThat(request.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue()).isEqualTo(APPLICATION_JSON);
assertThat(entity.isRepeatable()).isFalse();
assertThat(entity.getContentLength()).isEqualTo(-1);
assertThat(entity.isStreaming()).isTrue();
assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> entity.getContent());
assertThat(writeToString(entity)).isEqualTo("test");
assertThat(response.getContent()).isSameAs(this.content);
}
@Test
void putWithContentShouldExecuteHttpPut() throws Exception {
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.put(this.uri, APPLICATION_JSON,
(out) -> StreamUtils.copy("test", StandardCharsets.UTF_8, out));
verify(this.client).execute(this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
HttpEntity entity = ((HttpEntityEnclosingRequest) request).getEntity();
assertThat(request).isInstanceOf(HttpPut.class);
assertThat(request.getURI()).isEqualTo(this.uri);
assertThat(request.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue()).isEqualTo(APPLICATION_JSON);
assertThat(entity.isRepeatable()).isFalse();
assertThat(entity.getContentLength()).isEqualTo(-1);
assertThat(entity.isStreaming()).isTrue();
assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> entity.getContent());
assertThat(writeToString(entity)).isEqualTo("test");
assertThat(response.getContent()).isSameAs(this.content);
}
@Test
void deleteShouldExecuteHttpDelete() throws IOException {
given(this.entity.getContent()).willReturn(this.content);
given(this.statusLine.getStatusCode()).willReturn(200);
Response response = this.http.delete(this.uri);
verify(this.client).execute(this.requestCaptor.capture());
HttpUriRequest request = this.requestCaptor.getValue();
assertThat(request).isInstanceOf(HttpDelete.class);
assertThat(request.getURI()).isEqualTo(this.uri);
assertThat(request.getFirstHeader(HttpHeaders.CONTENT_TYPE)).isNull();
assertThat(response.getContent()).isSameAs(this.content);
}
@Test
void executeWhenResposeIsIn400RangeShouldThrowDockerException() throws ClientProtocolException, IOException {
given(this.entity.getContent()).willReturn(getClass().getResourceAsStream("errors.json"));
given(this.statusLine.getStatusCode()).willReturn(404);
assertThatExceptionOfType(DockerException.class).isThrownBy(() -> this.http.get(this.uri))
.satisfies((ex) -> assertThat(ex.getErrors()).hasSize(2));
}
@Test
void executeWhenResposeIsIn500RangeShouldThrowDockerException() throws ClientProtocolException, IOException {
given(this.statusLine.getStatusCode()).willReturn(500);
assertThatExceptionOfType(DockerException.class).isThrownBy(() -> this.http.get(this.uri))
.satisfies((ex) -> assertThat(ex.getErrors()).isNull());
}
private String writeToString(HttpEntity entity) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
entity.writeTo(out);
return new String(out.toByteArray(), StandardCharsets.UTF_8);
}
}

@ -0,0 +1,43 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudnativebuildpack.docker.ProgressUpdateEvent.ProgressDetail;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link LoadImageUpdateEvent}.
*
* @author Phillip Webb
*/
class LoadImageUpdateEventTests extends ProgressUpdateEventTests {
@Test
void getStreamReturnsStream() {
LoadImageUpdateEvent event = (LoadImageUpdateEvent) createEvent();
assertThat(event.getStream()).isEqualTo("stream");
}
@Override
protected ProgressUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress) {
return new LoadImageUpdateEvent("stream", status, progressDetail, progress);
}
}

@ -0,0 +1,64 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link LogUpdateEvent}.
*
* @author Phillip Webb
*/
class LogUpdateEventTests {
@Test
void readAllWhenSimpleStreamReturnsEvents() throws Exception {
List<LogUpdateEvent> events = readAll("log-update-event.stream");
assertThat(events).hasSize(7);
assertThat(events.get(0).toString())
.isEqualTo("Analyzing image '307c032c4ceaa6330b6c02af945a1fe56a8c3c27c28268574b217c1d38b093cf'");
assertThat(events.get(1).toString())
.isEqualTo("Writing metadata for uncached layer 'org.cloudfoundry.openjdk:openjdk-jre'");
assertThat(events.get(2).toString())
.isEqualTo("Using cached launch layer 'org.cloudfoundry.jvmapplication:executable-jar'");
}
@Test
void readAllWhenAnsiStreamReturnsEvents() throws Exception {
List<LogUpdateEvent> events = readAll("log-update-event-ansi.stream");
assertThat(events).hasSize(20);
assertThat(events.get(0).toString()).isEqualTo("");
assertThat(events.get(1).toString()).isEqualTo("Cloud Foundry OpenJDK Buildpack v1.0.64");
assertThat(events.get(2).toString()).isEqualTo(" OpenJDK JRE 11.0.5: Reusing cached layer");
}
private List<LogUpdateEvent> readAll(String name) throws IOException {
List<LogUpdateEvent> events = new ArrayList<>();
try (InputStream inputStream = getClass().getResourceAsStream(name)) {
LogUpdateEvent.readAll(inputStream, events::add);
}
return events;
}
}

@ -0,0 +1,75 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudnativebuildpack.docker.ProgressUpdateEvent.ProgressDetail;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ProgressUpdateEvent}.
*
* @author Phillip Webb
*/
abstract class ProgressUpdateEventTests {
@Test
void getStatusReturnsStatus() {
ProgressUpdateEvent event = createEvent();
assertThat(event.getStatus()).isEqualTo("status");
}
@Test
void getProgressDetailsReturnsProgresssDetails() {
ProgressUpdateEvent event = createEvent();
assertThat(event.getProgressDetail().getCurrent()).isEqualTo(1);
assertThat(event.getProgressDetail().getTotal()).isEqualTo(2);
}
@Test
void getProgressReturnsProgress() {
ProgressUpdateEvent event = createEvent();
assertThat(event.getProgress()).isEqualTo("progress");
}
@Test
void progressDetailIsEmptyWhenCurrentIsNullReturnsTrue() {
ProgressDetail detail = new ProgressDetail(null, 2);
assertThat(ProgressDetail.isEmpty(detail)).isTrue();
}
@Test
void progressDetailIsEmptyWhenTotalIsNullReturnsTrue() {
ProgressDetail detail = new ProgressDetail(1, null);
assertThat(ProgressDetail.isEmpty(detail)).isTrue();
}
@Test
void progressDetailIsEmptyWhenTotalAndCurrentAreNotNullReturnsFalse() {
ProgressDetail detail = new ProgressDetail(1, 2);
assertThat(ProgressDetail.isEmpty(detail)).isFalse();
}
protected ProgressUpdateEvent createEvent() {
return createEvent("status", new ProgressDetail(1, 2), "progress");
}
protected abstract ProgressUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress);
}

@ -0,0 +1,43 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudnativebuildpack.docker.ProgressUpdateEvent.ProgressDetail;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link PullImageUpdateEvent}.
*
* @author Phillip Webb
*/
class PullImageUpdateEventTests extends ProgressUpdateEventTests {
@Test
void getIdReturnsId() {
PullImageUpdateEvent event = (PullImageUpdateEvent) createEvent();
assertThat(event.getId()).isEqualTo("id");
}
@Override
protected ProgressUpdateEvent createEvent(String status, ProgressDetail progressDetail, String progress) {
return new PullImageUpdateEvent("id", status, progressDetail, progress);
}
}

@ -0,0 +1,63 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudnativebuildpack.json.AbstractJsonTests;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link PullImageUpdateEvent}.
*
* @author Phillip Webb
*/
class PullUpdateEventTests extends AbstractJsonTests {
@Test
void readValueWhenFullDeserializesJson() throws Exception {
PullImageUpdateEvent event = getObjectMapper().readValue(getContent("pull-update-full.json"),
PullImageUpdateEvent.class);
assertThat(event.getId()).isEqualTo("4f4fb700ef54");
assertThat(event.getStatus()).isEqualTo("Extracting");
assertThat(event.getProgressDetail().getCurrent()).isEqualTo(16);
assertThat(event.getProgressDetail().getTotal()).isEqualTo(32);
assertThat(event.getProgress()).isEqualTo("[==================================================>] 32B/32B");
}
@Test
void readValueWhenMinimalDeserializesJson() throws Exception {
PullImageUpdateEvent event = getObjectMapper().readValue(getContent("pull-update-minimal.json"),
PullImageUpdateEvent.class);
assertThat(event.getId()).isNull();
assertThat(event.getStatus()).isEqualTo("Status: Downloaded newer image for cloudfoundry/cnb:bionic");
assertThat(event.getProgressDetail()).isNull();
assertThat(event.getProgress()).isNull();
}
@Test
void readValueWhenEmptyDetailsDeserializesJson() throws Exception {
PullImageUpdateEvent event = getObjectMapper().readValue(getContent("pull-with-empty-details.json"),
PullImageUpdateEvent.class);
assertThat(event.getId()).isEqualTo("d837a2a1365e");
assertThat(event.getStatus()).isEqualTo("Pulling fs layer");
assertThat(event.getProgressDetail()).isNull();
assertThat(event.getProgress()).isNull();
}
}

@ -0,0 +1,85 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link TotalProgressBar}.
*
* @author Phillip Webb
*/
class TotalProgressBarTests {
@Test
void withPrefixAndBookends() {
TestPrintStream out = new TestPrintStream();
TotalProgressBar bar = new TotalProgressBar("prefix:", '#', true, out);
assertThat(out).hasToString("prefix: [ ");
bar.accept(new TotalProgressEvent(10));
assertThat(out.toString()).isEqualTo("prefix: [ #####");
bar.accept(new TotalProgressEvent(50));
assertThat(out.toString()).isEqualTo("prefix: [ #########################");
bar.accept(new TotalProgressEvent(100));
assertThat(out.toString()).isEqualTo("prefix: [ ################################################## ]\n");
}
@Test
void withoutPrefix() {
TestPrintStream out = new TestPrintStream();
TotalProgressBar bar = new TotalProgressBar(null, '#', true, out);
assertThat(out).hasToString("[ ");
bar.accept(new TotalProgressEvent(10));
assertThat(out.toString()).isEqualTo("[ #####");
bar.accept(new TotalProgressEvent(50));
assertThat(out.toString()).isEqualTo("[ #########################");
bar.accept(new TotalProgressEvent(100));
assertThat(out.toString()).isEqualTo("[ ################################################## ]\n");
}
@Test
void withoutBookends() {
TestPrintStream out = new TestPrintStream();
TotalProgressBar bar = new TotalProgressBar("", '.', false, out);
assertThat(out).hasToString("");
bar.accept(new TotalProgressEvent(10));
assertThat(out.toString()).isEqualTo(".....");
bar.accept(new TotalProgressEvent(50));
assertThat(out.toString()).isEqualTo(".........................");
bar.accept(new TotalProgressEvent(100));
assertThat(out.toString()).isEqualTo("..................................................\n");
}
static class TestPrintStream extends PrintStream {
TestPrintStream() {
super(new ByteArrayOutputStream());
}
@Override
public String toString() {
return this.out.toString();
}
}
}

@ -0,0 +1,50 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link TotalProgressEvent}.
*
* @author Phillip Webb
*/
class TotalProgressEventTests {
@Test
void create() {
assertThat(new TotalProgressEvent(0).getPercent()).isEqualTo(0);
assertThat(new TotalProgressEvent(10).getPercent()).isEqualTo(10);
assertThat(new TotalProgressEvent(100).getPercent()).isEqualTo(100);
}
@Test
void createWhenPercentLessThanZeroThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> new TotalProgressEvent(-1))
.withMessage("Percent must be in the range 0 to 100");
}
@Test
void createWhenEventMoreThanOneHundredThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> new TotalProgressEvent(101))
.withMessage("Percent must be in the range 0 to 100");
}
}

@ -0,0 +1,85 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudnativebuildpack.json.AbstractJsonTests;
import org.springframework.boot.cloudnativebuildpack.json.JsonStream;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link TotalProgressPullListener}.
*
* @author Phillip Webb
*/
class TotalProgressPullListenerTests extends AbstractJsonTests {
@Test
void totalProgress() throws Exception {
List<Integer> progress = new ArrayList<>();
TotalProgressPullListener listener = new TotalProgressPullListener((event) -> progress.add(event.getPercent()));
run(listener);
int last = 0;
for (Integer update : progress) {
assertThat(update).isGreaterThanOrEqualTo(last);
last = update;
}
assertThat(last).isEqualTo(100);
}
@Test
@Disabled("For visual inspection")
void totalProgressUpdatesSmoothly() throws Exception {
TestTotalProgressPullListener listener = new TestTotalProgressPullListener(
new TotalProgressBar("Pulling layers:"));
run(listener);
}
private void run(TotalProgressPullListener listener) throws IOException {
JsonStream jsonStream = new JsonStream(getObjectMapper());
listener.onStart();
jsonStream.get(getContent("pull-stream.json"), PullImageUpdateEvent.class, listener::onUpdate);
listener.onFinish();
}
private static class TestTotalProgressPullListener extends TotalProgressPullListener {
TestTotalProgressPullListener(Consumer<TotalProgressEvent> consumer) {
super(consumer);
}
@Override
public void onUpdate(PullImageUpdateEvent event) {
super.onUpdate(event);
try {
Thread.sleep(10);
}
catch (InterruptedException ex) {
}
}
}
}

@ -0,0 +1,67 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.boot.cloudnativebuildpack.json.AbstractJsonTests;
import org.springframework.util.StreamUtils;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link ContainerConfig}.
*
* @author Phillip Webb
*/
class ContainerConfigTests extends AbstractJsonTests {
@Test
void ofWhenImageReferenceIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> ContainerConfig.of(null, (update) -> {
})).withMessage("ImageReference must not be null");
}
@Test
void ofWhenUpdateIsNullThrowsException() {
ImageReference imageReference = ImageReference.of("ubuntu:bionic");
assertThatIllegalArgumentException().isThrownBy(() -> ContainerConfig.of(imageReference, null))
.withMessage("Update must not be null");
}
@Test
void writeToWritesJson() throws Exception {
ImageReference imageReference = ImageReference.of("ubuntu:bionic");
ContainerConfig containerConfig = ContainerConfig.of(imageReference, (update) -> {
update.withUser("root");
update.withCommand("ls", "-l");
update.withArgs("-h");
update.withLabel("spring", "boot");
update.withBind("bind-source", "bind-dest");
});
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
containerConfig.writeTo(outputStream);
String actualJson = new String(outputStream.toByteArray(), StandardCharsets.UTF_8);
String expectedJson = StreamUtils.copyToString(getContent("container-config.json"), StandardCharsets.UTF_8);
JSONAssert.assertEquals(expectedJson, actualJson, false);
}
}

@ -0,0 +1,70 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.cloudnativebuildpack.docker.type;
import org.junit.jupiter.api.Test;
import org.springframework.boot.cloudnativebuildpack.io.TarArchive;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link ContainerContent}.
*
* @author Phillip Webb
*/
class ContainerContentTests {
@Test
void ofWhenArchiveIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> ContainerContent.of(null))
.withMessage("Archive must not be null");
}
@Test
void ofWhenDestinationPathIsNullThrowsException() {
TarArchive archive = mock(TarArchive.class);
assertThatIllegalArgumentException().isThrownBy(() -> ContainerContent.of(archive, null))
.withMessage("DestinationPath must not be empty");
}
@Test
void ofWhenDestinationPathIsEmptyThrowsException() {
TarArchive archive = mock(TarArchive.class);
assertThatIllegalArgumentException().isThrownBy(() -> ContainerContent.of(archive, ""))
.withMessage("DestinationPath must not be empty");
}
@Test
void ofCreatesContainerContent() {
TarArchive archive = mock(TarArchive.class);
ContainerContent content = ContainerContent.of(archive);
assertThat(content.getArchive()).isSameAs(archive);
assertThat(content.getDestinationPath()).isEqualTo("/");
}
@Test
void ofWithDestinationPathCreatesContainerContent() {
TarArchive archive = mock(TarArchive.class);
ContainerContent content = ContainerContent.of(archive, "/test");
assertThat(content.getArchive()).isSameAs(archive);
assertThat(content.getDestinationPath()).isEqualTo("/test");
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save