Commit 16e6bc89 authored by Phillip Webb's avatar Phillip Webb

Create a new ImagePackager tools class

Pull functionality from `Repackager` into a new `Packager` base class
and develop a variant for Docker image creation. The new `ImagePackager`
class provides a general purpose way to construct jar entries without
being tied to an actual file. This will allow us to link it to a
buildpack and provide application content directly.

Closes gh-19834
parent aa195471
......@@ -16,11 +16,10 @@ configurations {
dependencies {
api platform(project(':spring-boot-project:spring-boot-parent'))
api "org.apache.commons:commons-compress"
api "org.springframework:spring-core"
compileOnly "ch.qos.logback:logback-classic"
implementation "org.springframework:spring-core"
loader project(":spring-boot-project:spring-boot-tools:spring-boot-loader")
testImplementation "org.assertj:assertj-core"
......
/*
* 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.loader.tools;
import java.io.IOException;
import java.io.OutputStream;
/**
* Interface used to write jar entry data.
*
* @author Phillip Webb
* @since 2.3.0
*/
@FunctionalInterface
public interface EntryWriter {
/**
* Write entry data to the specified output stream.
* @param outputStream the destination for the data
* @throws IOException in case of I/O errors
*/
void write(OutputStream outputStream) throws IOException;
/**
* Return the size of the content that will be written, or {@code -1} if the size is
* not known.
* @return the size of the content
*/
default int size() {
return -1;
}
}
/*
* 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.loader.tools;
import java.io.File;
import java.io.IOException;
import java.util.function.BiConsumer;
import java.util.jar.JarFile;
import java.util.zip.ZipEntry;
import org.springframework.util.Assert;
/**
* Utility class that can be used to export a fully packaged archive to an OCI image.
*
* @author Phillip Webb
* @since 2.3.0
*/
public class ImagePackager extends Packager {
/**
* Create a new {@link ImagePackager} instance.
* @param source the source file to package
*/
public ImagePackager(File source) {
super(source, null);
}
/**
* Create an packaged image.
* @param libraries the contained libraries
* @param exporter the exporter used to write the image
* @throws IOException on IO error
*/
public void packageImage(Libraries libraries, BiConsumer<ZipEntry, EntryWriter> exporter) throws IOException {
packageImage(libraries, new DelegatingJarWriter(exporter));
}
private void packageImage(Libraries libraries, AbstractJarWriter writer) throws IOException {
File source = isAlreadyPackaged() ? getBackupFile() : getSource();
Assert.state(source.exists() && source.isFile(), "Unable to read jar file " + source);
Assert.state(!isAlreadyPackaged(source), "Repackaged jar file " + source + " cannot be exported");
try (JarFile sourceJar = new JarFile(source)) {
write(sourceJar, libraries, writer);
}
}
/**
* {@link AbstractJarWriter} that delegates to a {@link BiConsumer}.
*/
private static class DelegatingJarWriter extends AbstractJarWriter {
private BiConsumer<ZipEntry, EntryWriter> exporter;
DelegatingJarWriter(BiConsumer<ZipEntry, EntryWriter> exporter) {
this.exporter = exporter;
}
@Override
protected void writeToArchive(ZipEntry entry, EntryWriter entryWriter) throws IOException {
this.exporter.accept(entry, entryWriter);
}
}
}
/*
* 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.loader.tools;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import org.springframework.util.StreamUtils;
/**
* {@link EntryWriter} that always provides size information.
*
* @author Phillip Webb
*/
final class SizeCalculatingEntryWriter implements EntryWriter {
static final int THRESHOLD = 1024 * 20;
private final Object content;
private final int size;
private SizeCalculatingEntryWriter(EntryWriter entryWriter) throws IOException {
SizeCalculatingOutputStream outputStream = new SizeCalculatingOutputStream();
try {
entryWriter.write(outputStream);
}
finally {
outputStream.close();
}
this.content = outputStream.getContent();
this.size = outputStream.getSize();
}
@Override
public void write(OutputStream outputStream) throws IOException {
InputStream inputStream = getContentInputStream();
copy(inputStream, outputStream);
}
private InputStream getContentInputStream() throws FileNotFoundException {
if (this.content instanceof File) {
return new FileInputStream((File) this.content);
}
return new ByteArrayInputStream((byte[]) this.content);
}
private void copy(InputStream inputStream, OutputStream outputStream) throws IOException {
try {
StreamUtils.copy(inputStream, outputStream);
}
finally {
inputStream.close();
}
}
@Override
public int size() {
return this.size;
}
static EntryWriter get(EntryWriter entryWriter) throws IOException {
if (entryWriter == null || entryWriter.size() != -1) {
return entryWriter;
}
return new SizeCalculatingEntryWriter(entryWriter);
}
/**
* {@link OutputStream} to calculate the size and allow content to be written again.
*/
private static class SizeCalculatingOutputStream extends OutputStream {
private int size = 0;
private File tempFile;
private OutputStream outputStream;
SizeCalculatingOutputStream() throws IOException {
this.tempFile = File.createTempFile("springboot-", "-entrycontent");
this.outputStream = new ByteArrayOutputStream();
}
@Override
public void write(int b) throws IOException {
write(new byte[] { (byte) b }, 0, 1);
}
@Override
public void write(byte[] b, int off, int len) throws IOException {
int updatedSize = this.size + len;
if (updatedSize > THRESHOLD && this.outputStream instanceof ByteArrayOutputStream) {
this.outputStream = convertToFileOutputStream((ByteArrayOutputStream) this.outputStream);
}
this.outputStream.write(b, off, len);
this.size = updatedSize;
}
private OutputStream convertToFileOutputStream(ByteArrayOutputStream byteArrayOutputStream) throws IOException {
FileOutputStream fileOutputStream = new FileOutputStream(this.tempFile);
StreamUtils.copy(byteArrayOutputStream.toByteArray(), fileOutputStream);
return fileOutputStream;
}
@Override
public void close() throws IOException {
this.outputStream.close();
}
Object getContent() {
return (this.outputStream instanceof ByteArrayOutputStream)
? ((ByteArrayOutputStream) this.outputStream).toByteArray() : this.tempFile;
}
int getSize() {
return this.size;
}
}
}
/*
* 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.loader.tools;
import java.io.ByteArrayInputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
/**
* {@link InputStream} that can peek ahead at zip header bytes.
*
* @author Phillip Webb
*/
class ZipHeaderPeekInputStream extends FilterInputStream {
private static final byte[] ZIP_HEADER = new byte[] { 0x50, 0x4b, 0x03, 0x04 };
private final byte[] header;
private final int headerLength;
private int position;
private ByteArrayInputStream headerStream;
protected ZipHeaderPeekInputStream(InputStream in) throws IOException {
super(in);
this.header = new byte[4];
this.headerLength = in.read(this.header);
this.headerStream = new ByteArrayInputStream(this.header, 0, this.headerLength);
}
@Override
public int read() throws IOException {
int read = (this.headerStream != null) ? this.headerStream.read() : -1;
if (read != -1) {
this.position++;
if (this.position >= this.headerLength) {
this.headerStream = null;
}
return read;
}
return super.read();
}
@Override
public int read(byte[] b) throws IOException {
return read(b, 0, b.length);
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
int read = (this.headerStream != null) ? this.headerStream.read(b, off, len) : -1;
if (read <= 0) {
return readRemainder(b, off, len);
}
this.position += read;
if (read < len) {
int remainderRead = readRemainder(b, off + read, len - read);
if (remainderRead > 0) {
read += remainderRead;
}
}
if (this.position >= this.headerLength) {
this.headerStream = null;
}
return read;
}
boolean hasZipHeader() {
return Arrays.equals(this.header, ZIP_HEADER);
}
private int readRemainder(byte[] b, int off, int len) throws IOException {
int read = super.read(b, off, len);
if (read > 0) {
this.position += read;
}
return read;
}
}
/*
* 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.loader.tools;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.jar.Manifest;
import java.util.zip.ZipEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
/**
* Tests for {@link ImagePackager}
*
* @author Phillip Webb
*/
class ImagePackagerTests extends AbstractPackagerTests<ImagePackager> {
private Map<ZipArchiveEntry, byte[]> entries;
@Override
protected ImagePackager createPackager(File source) {
return new ImagePackager(source);
}
@Override
protected void execute(ImagePackager packager, Libraries libraries) throws IOException {
this.entries = new LinkedHashMap<>();
packager.packageImage(libraries, this::save);
}
private void save(ZipEntry entry, EntryWriter writer) {
try {
this.entries.put((ZipArchiveEntry) entry, getContent(writer));
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
private byte[] getContent(EntryWriter writer) throws IOException {
if (writer == null) {
return null;
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
writer.write(outputStream);
return outputStream.toByteArray();
}
@Override
protected Collection<ZipArchiveEntry> getAllPackagedEntries() throws IOException {
return this.entries.keySet();
}
@Override
protected Manifest getPackagedManifest() throws IOException {
byte[] bytes = getEntryBytes("META-INF/MANIFEST.MF");
return (bytes != null) ? new Manifest(new ByteArrayInputStream(bytes)) : null;
}
@Override
protected String getPackagedEntryContent(String name) throws IOException {
byte[] bytes = getEntryBytes(name);
return (bytes != null) ? new String(bytes, StandardCharsets.UTF_8) : null;
}
private byte[] getEntryBytes(String name) throws IOException {
ZipEntry entry = getPackagedEntry(name);
return (entry != null) ? this.entries.get(entry) : 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.loader.tools;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Random;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link SizeCalculatingEntryWriter}.
*
* @author Phillip Webb
*/
class SizeCalculatingEntryWriterTests {
@Test
void getWhenWithinThreshold() throws Exception {
TestEntryWriter original = new TestEntryWriter(SizeCalculatingEntryWriter.THRESHOLD - 1);
EntryWriter writer = SizeCalculatingEntryWriter.get(original);
assertThat(writer.size()).isEqualTo(original.getBytes().length);
assertThat(writeBytes(writer)).isEqualTo(original.getBytes());
assertThat(writer).extracting("content").isNotInstanceOf(File.class);
}
@Test
void getWhenExceedingThreshold() throws Exception {
TestEntryWriter original = new TestEntryWriter(SizeCalculatingEntryWriter.THRESHOLD + 1);
EntryWriter writer = SizeCalculatingEntryWriter.get(original);
assertThat(writer.size()).isEqualTo(original.getBytes().length);
assertThat(writeBytes(writer)).isEqualTo(original.getBytes());
assertThat(writer).extracting("content").isInstanceOf(File.class);
}
private byte[] writeBytes(EntryWriter writer) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
writer.write(outputStream);
outputStream.close();
return outputStream.toByteArray();
}
private static class TestEntryWriter implements EntryWriter {
private byte[] bytes;
TestEntryWriter(int size) {
this.bytes = new byte[size];
new Random().nextBytes(this.bytes);
}
byte[] getBytes() {
return this.bytes;
}
@Override
public void write(OutputStream outputStream) throws IOException {
outputStream.write(this.bytes);
}
}
}
/*
* Copyright 2012-2019 the original author or authors.
* 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.
......@@ -21,8 +21,6 @@ import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.springframework.boot.loader.tools.JarWriter.ZipHeaderPeekInputStream;
import static org.assertj.core.api.Assertions.assertThat;
/**
......
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