Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,39 @@

package org.springframework.cloud.function.adapter.gcp;

import java.net.URL;
import java.net.URLStreamHandler;
import java.net.URLStreamHandlerFactory;

import com.google.cloud.functions.Context;
import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import com.google.cloud.functions.RawBackgroundFunction;

import org.springframework.boot.loader.launch.JarLauncher;
import org.springframework.boot.loader.net.protocol.Handlers;
import org.springframework.boot.loader.net.protocol.nested.Handler;

/**
* The launcher class written at the top-level of the output JAR to be deployed to
* Google Cloud Functions. This is the entry point to the function when run from JAR.
*
* @author Ray Tsang
* @author Daniel Zou
* @author Roman Akentev
*/
public class GcfJarLauncher extends JarLauncher implements HttpFunction, RawBackgroundFunction {

private static final URLStreamHandlerFactory NESTED_URL_STREAM_HANDLER_FACTORY = new NestedUrlStreamHandlerFactory();

private final ClassLoader loader;

private final Object delegate;

public GcfJarLauncher() throws Exception {
//JarFile.registerUrlProtocolHandler();
Handlers.register();
registerNestedUrlStreamHandlerFactory();

this.loader = createClassLoader(getClassPathUrls());

Expand All @@ -47,6 +57,25 @@ public GcfJarLauncher() throws Exception {
this.delegate = clazz.getConstructor().newInstance();
}

/**
* Install a {@link URLStreamHandlerFactory} that provides the {@code nested:}
* handler directly, without relying on the {@code java.protocol.handler.pkgs}
* property. That property is only consulted by the JVM through the bootstrap
* and system classloaders, so it cannot find the handler when the loader
* classes are only visible to the classloader of the deployed fat JAR (e.g.
* when the Google Cloud Functions framework loads the JAR in a child
* {@code URLClassLoader}).
*/
private void registerNestedUrlStreamHandlerFactory() {
try {
URL.setURLStreamHandlerFactory(NESTED_URL_STREAM_HANDLER_FACTORY);
}
catch (Error error) {
// A factory is already installed. Handlers.register() above may still be
// sufficient when the loader classes are on the system classpath.
}
}

@Override
public void service(HttpRequest httpRequest, HttpResponse httpResponse) throws Exception {
Thread.currentThread().setContextClassLoader(this.loader);
Expand All @@ -58,5 +87,15 @@ public void accept(String json, Context context) throws Exception {
Thread.currentThread().setContextClassLoader(this.loader);
((RawBackgroundFunction) delegate).accept(json, context);
}

private static final class NestedUrlStreamHandlerFactory implements URLStreamHandlerFactory {

@Override
public URLStreamHandler createURLStreamHandler(String protocol) {
return ("nested".equals(protocol)) ? new Handler() : null;
}

}

}

Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/*
* Copyright 2018-present 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.cloud.function.adapter.gcp;

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Enumeration;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import com.google.cloud.functions.HttpFunction;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import org.springframework.boot.loader.launch.Launcher;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Tests verifying that {@link GcfJarLauncher} registers the {@code nested:}
* URL protocol handler before creating its classloader (GH-1336).
* <p>
* The test builds a Spring Boot fat JAR, then forks a subprocess that loads
* {@code GcfJarLauncher} from that JAR through a child {@code URLClassLoader}
* - mirroring how the Google Cloud Functions framework loads the deployed
* function JAR. In that setup spring-boot-loader is only visible inside the
* fat JAR, so the launcher must install a {@code nested:} handler that the
* JVM can resolve without the loader classes being on the system classpath.
* The subprocess is also checked to fail on the {@code nested:} protocol
* before the launcher is constructed, proving the handler is not already
* registered by anything else.
*
* @author Roman Akentev
* @see <a href="https://github.com/spring-cloud/spring-cloud-function/issues/1336">GH-1336</a>
*/
public class GcfJarLauncherTests {

public static final String PROTOCOL_ALREADY_REGISTERED = "PROTOCOL_ALREADY_REGISTERED";

public static final String PROTOCOL_NOT_REGISTERED = "PROTOCOL_NOT_REGISTERED";

public static final String GCF_JAR_LAUNCHER_SUCCEEDED = "GCF_JAR_LAUNCHER_SUCCEEDED";

public static final String GCF_JAR_LAUNCHER_FAILED = "GCF_JAR_LAUNCHER_FAILED";

public static final String PROTOCOL_NOW_REGISTERED = "PROTOCOL_NOW_REGISTERED";

public static final String PROTOCOL_STILL_NOT_REGISTERED = "PROTOCOL_STILL_NOT_REGISTERED";

private static final String GCF_LAUNCHER_CLASS_NAME = "org.springframework.cloud.function.adapter.gcp.GcfJarLauncher";

private static final String FUNCTION_INVOKER_CLASS_NAME = "org.springframework.cloud.function.adapter.gcp.FunctionInvoker";

@TempDir
Path tempDir;

@Test
public void nestedProtocolIsRegisteredByGcfJarLauncher() throws Exception {
String javaHome = System.getProperty("java.home");
Path classesDir = tempDir.resolve("classes");
compile(javaHome, classesDir);

Path functionJar = createFunctionJar(classesDir);
Path frameworkApiJar = codeSource(HttpFunction.class);

Process process = new ProcessBuilder(Path.of(javaHome, "bin", "java").toString(), "-cp",
String.join(File.pathSeparator, functionJar.toString(), frameworkApiJar.toString(),
classesDir.toString()),
"ProtocolCheck", functionJar.toString())
.redirectErrorStream(true)
.start();
boolean finished = process.waitFor(2, TimeUnit.MINUTES);
String output = new BufferedReader(new InputStreamReader(process.getInputStream())).lines()
.collect(Collectors.joining("\n"));
if (!finished) {
process.destroyForcibly();
}

assertThat(finished).as("ProtocolCheck timed out:\n" + output).isTrue();
assertThat(process.exitValue()).as("ProtocolCheck exit code:\n" + output).isZero();
assertThat(output).as("ProtocolCheck output")
.contains(PROTOCOL_NOT_REGISTERED + ": unknown protocol: nested")
.contains(GCF_JAR_LAUNCHER_SUCCEEDED)
.contains(PROTOCOL_NOW_REGISTERED);
}

private void compile(String javaHome, Path classesDir) throws Exception {
Files.createDirectories(classesDir);
Path protocolCheck = tempDir.resolve("ProtocolCheck.java");
writeResource(protocolCheck, "/ProtocolCheck.java");
Path stubInvoker = tempDir.resolve("FunctionInvoker.java");
writeResource(stubInvoker, "/StubFunctionInvoker.java");
Process compile = new ProcessBuilder(Path.of(javaHome, "bin", "javac").toString(), "-d",
classesDir.toString(), protocolCheck.toString(), stubInvoker.toString())
.redirectErrorStream(true)
.start();
String compileOutput = new BufferedReader(new InputStreamReader(compile.getInputStream())).lines()
.collect(Collectors.joining("\n"));
boolean finished = compile.waitFor(1, TimeUnit.MINUTES);
if (!finished) {
compile.destroyForcibly();
}
assertThat(finished).as("javac timed out").isTrue();
assertThat(compile.exitValue()).as("compilation failed:\n" + compileOutput).isZero();
}

private Path createFunctionJar(Path classesDir) throws Exception {
Path functionJar = tempDir.resolve("function.jar");
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(functionJar))) {
writeEntry(zip, "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n".getBytes(StandardCharsets.UTF_8));
Path loaderJar = codeSource(Launcher.class);
try (JarFile loader = new JarFile(loaderJar.toFile())) {
Enumeration<JarEntry> entries = loader.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
if (!entry.isDirectory() && !entry.getName().startsWith("META-INF/")) {
writeEntry(zip, entry.getName(), loader.getInputStream(entry).readAllBytes());
}
}
}
writeLauncherClasses(zip);
writeEntry(zip, "BOOT-INF/lib/spring-cloud-function-adapter-gcp.jar", nestedAdapterJar(classesDir));
}
return functionJar;
}

private void writeLauncherClasses(ZipOutputStream zip) throws Exception {
Path adapterClasses = codeSource(GcfJarLauncher.class);
if (!Files.isDirectory(adapterClasses)) {
throw new IllegalStateException("Adapter classes are not on the classpath as a directory: " + adapterClasses);
}
String launcherPrefix = GCF_LAUNCHER_CLASS_NAME.replace('.', '/');
try (Stream<Path> paths = Files.walk(adapterClasses)) {
for (Path path : paths.toList()) {
if (!Files.isRegularFile(path)) {
continue;
}
String entryName = adapterClasses.relativize(path).toString().replace(File.separatorChar, '/');
if (entryName.startsWith(launcherPrefix) && entryName.endsWith(".class")) {
writeEntry(zip, entryName, Files.readAllBytes(path));
}
}
}
}

private byte[] nestedAdapterJar(Path classesDir) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
writeEntry(zip, "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n".getBytes(StandardCharsets.UTF_8));
Path stub = classesDir.resolve(FUNCTION_INVOKER_CLASS_NAME.replace('.', '/') + ".class");
writeEntry(zip, FUNCTION_INVOKER_CLASS_NAME.replace('.', '/') + ".class", Files.readAllBytes(stub));
}
return bytes.toByteArray();
}

private static void writeResource(Path target, String resource) throws IOException {
try (InputStream in = Objects.requireNonNull(GcfJarLauncherTests.class.getResourceAsStream(resource),
"Resource " + resource + " not found")) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
}

private static Path codeSource(Class<?> type) throws Exception {
return Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI());
}

private static void writeEntry(ZipOutputStream zip, String name, byte[] content) throws IOException {
zip.putNextEntry(new ZipEntry(name));
zip.write(content);
zip.closeEntry();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;

/**
* Subprocess entry point for {@code GcfJarLauncherTests}. Loads
* {@code GcfJarLauncher} from a Spring Boot fat JAR through a child
* {@link URLClassLoader}, mirroring how the Google Cloud Functions framework
* loads the deployed function JAR, and reports whether the {@code nested:}
* URL protocol is registered before and after the launcher is constructed
* (GH-1336).
*/
public class ProtocolCheck {

public static void main(String[] args) {
File functionJar = new File(args[0]);
try {
new URL("nested:/dev/null");
System.out.println("PROTOCOL_ALREADY_REGISTERED");
}
catch (MalformedURLException ex) {
System.out.println("PROTOCOL_NOT_REGISTERED: " + ex.getMessage());
}
try (URLClassLoader child = new URLClassLoader(new URL[] { toUrl(functionJar) },
ClassLoader.getSystemClassLoader())) {
Class<?> launcher = Class.forName("org.springframework.cloud.function.adapter.gcp.GcfJarLauncher",
true, child);
launcher.getConstructor().newInstance();
System.out.println("GCF_JAR_LAUNCHER_SUCCEEDED");
}
catch (Throwable ex) {
System.out.println("GCF_JAR_LAUNCHER_FAILED: " + ex);
ex.printStackTrace(System.out);
}
try {
new URL("nested:/dev/null");
System.out.println("PROTOCOL_NOW_REGISTERED");
}
catch (MalformedURLException ex) {
System.out.println("PROTOCOL_STILL_NOT_REGISTERED: " + ex.getMessage());
}
}

private static URL toUrl(File file) {
try {
return file.toURI().toURL();
}
catch (MalformedURLException ex) {
throw new IllegalStateException(ex);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.springframework.cloud.function.adapter.gcp;

/**
* Minimal stand-in for the real {@code FunctionInvoker} used by
* {@code GcfJarLauncherTests}. The real class would bootstrap the entire
* Spring application in its constructor, which is not needed to verify that
* {@code GcfJarLauncher} can load its delegate from a nested JAR.
*/
public class FunctionInvoker {

public FunctionInvoker() {
}

}
Loading