Commit aa195471 authored by Phillip Webb's avatar Phillip Webb

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
parent 7fe79f35
...@@ -10,7 +10,7 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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'
......
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'
}
\ No newline at end of file
/*
* 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);
}
/*
* 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);
}
}
}
/*
* 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);
}
}
/*
* 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);
}
}
/*
* 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");
}
}
/*
* 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();
}
}
}
/*
* 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);
}
}
}
/*
* 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();
}
}
/*
* 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]);
}
}
/*
* 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));
}
}
/*
* 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);
}
}
/*
* 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);
}
}
/*
* 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;
/*
* 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;
}
}
/*
* 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;
}
}
/*
* 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();
}
}
/*
* 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();
}
}
/*
* 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");
}
}
/*
* 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;
}
}
}
/*
* 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;
}
}
/*
* 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();
}
}
}
/*
* 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;
}
}
/*
* 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
}
}
/*
* 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;
}
}
}
/*
* 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;
}
}
/*
* 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 ? " ]" : "");
}
}
}
/*
* 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;
}
}
/*
* 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);
}
}
}
/*
* 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 {
}
/*
* 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;
}
}
/*
* 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;
/*
* 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);
}
}
}
/*
* 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;
}
};
}
}
/*
* 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);
}
}
/*
* 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);
}
}
/*
* 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();
}
}
}
/*
* 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);
}
}
}
/*
* 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 };
}
}
/*
* 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);
}
}
/*
* 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;
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment