From d7f3609601263cbc6339e26005c2952a86a2dab9 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Tue, 23 Jan 2024 18:37:06 +0100 Subject: [PATCH] feat: native lib jar can be downloaded from remote repo --- .../com/marcnuri/helm/NativeLibraryTest.java | 74 ++++++++++ lib/api/pom.xml | 13 ++ .../com/marcnuri/helm/jni/NativeLibrary.java | 22 ++- .../marcnuri/helm/jni/RemoteJarLoader.java | 127 ++++++++++++++++++ .../META-INF/native-libraries.properties | 4 + pom.xml | 4 +- 6 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 helm-java/src/test/java/com/marcnuri/helm/NativeLibraryTest.java create mode 100644 lib/api/src/main/java/com/marcnuri/helm/jni/RemoteJarLoader.java create mode 100644 lib/api/src/main/resources-filtered/META-INF/native-libraries.properties diff --git a/helm-java/src/test/java/com/marcnuri/helm/NativeLibraryTest.java b/helm-java/src/test/java/com/marcnuri/helm/NativeLibraryTest.java new file mode 100644 index 0000000..bf08dfc --- /dev/null +++ b/helm-java/src/test/java/com/marcnuri/helm/NativeLibraryTest.java @@ -0,0 +1,74 @@ +package com.marcnuri.helm; + +import com.marcnuri.helm.jni.NativeLibrary; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.net.URLClassLoader; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class NativeLibraryTest { + + @Test + void getInstanceFromClassPath() { + System.setProperty("com.marcnuri.jkube-helm.skipRemoteJar", "true"); + try { + NativeLibrary nativeLibrary = NativeLibrary.getInstance(); + assertThat(nativeLibrary).isNotNull(); + } finally { + System.clearProperty("com.marcnuri.jkube-helm.skipRemoteJar"); + } + } + + @Test + void getSnapshotInstanceFromRemoteJar() { + final ClassLoader currentClassLoader = Thread.currentThread().getContextClassLoader(); + // Use a PUBLISHED snapshot version + System.setProperty("com.marcnuri.jkube-helm.version", "0.0-SNAPSHOT"); + System.setProperty("com.marcnuri.jkube-helm.forceUpdate", "true"); + try { + Thread.currentThread().setContextClassLoader(new URLClassLoader(new java.net.URL[0], null)); + assertThat(NativeLibrary.serviceProviderLibrary(null)).isNull(); + NativeLibrary nativeLibrary = NativeLibrary.getInstance(); + assertThat(nativeLibrary).isNotNull(); + } finally { + Thread.currentThread().setContextClassLoader(currentClassLoader); + System.clearProperty("com.marcnuri.jkube-helm.version"); + } + } + + @Test + @Disabled("Can only be enabled once we do an initial stable release") // TODO + void getReleaseInstanceFromRemoteJar() { + final ClassLoader currentClassLoader = Thread.currentThread().getContextClassLoader(); + // Use a PUBLISHED snapshot version + System.setProperty("com.marcnuri.jkube-helm.version", "0.0.0"); + System.setProperty("com.marcnuri.jkube-helm.forceUpdate", "true"); + try { + Thread.currentThread().setContextClassLoader(new URLClassLoader(new java.net.URL[0], null)); + assertThat(NativeLibrary.serviceProviderLibrary(null)).isNull(); + NativeLibrary nativeLibrary = NativeLibrary.getInstance(); + assertThat(nativeLibrary).isNotNull(); + } finally { + Thread.currentThread().setContextClassLoader(currentClassLoader); + System.clearProperty("com.marcnuri.jkube-helm.version"); + } + } + + @Test + void getInstanceNotAvailable() { + final ClassLoader currentClassLoader = Thread.currentThread().getContextClassLoader(); + System.setProperty("com.marcnuri.jkube-helm.skipRemoteJar", "true"); + try { + Thread.currentThread().setContextClassLoader(new URLClassLoader(new java.net.URL[0], null)); + assertThatThrownBy(NativeLibrary::getInstance) + .isInstanceOf(IllegalStateException.class) + .hasMessage("No NativeLibrary implementation found, please add one of the supported dependencies to your project"); + } finally { + System.clearProperty("com.marcnuri.jkube-helm.skipRemoteJar"); + Thread.currentThread().setContextClassLoader(currentClassLoader); + } + } +} diff --git a/lib/api/pom.xml b/lib/api/pom.xml index 2733d0a..3990ed1 100644 --- a/lib/api/pom.xml +++ b/lib/api/pom.xml @@ -29,4 +29,17 @@ jna-platform-jpms + + + + + src/main/resources + false + + + src/main/resources-filtered + true + + + diff --git a/lib/api/src/main/java/com/marcnuri/helm/jni/NativeLibrary.java b/lib/api/src/main/java/com/marcnuri/helm/jni/NativeLibrary.java index ee21226..baec191 100644 --- a/lib/api/src/main/java/com/marcnuri/helm/jni/NativeLibrary.java +++ b/lib/api/src/main/java/com/marcnuri/helm/jni/NativeLibrary.java @@ -10,15 +10,34 @@ import java.util.Objects; import java.util.ServiceLoader; +import static com.marcnuri.helm.jni.RemoteJarLoader.remoteJar; + public interface NativeLibrary { static NativeLibrary getInstance() { - for (NativeLibrary nativeLibrary : ServiceLoader.load(NativeLibrary.class)) { + NativeLibrary nativeLibrary; + ClassLoader remoteJar; + if ( + // Load from ClassPath (should work on Maven) + (nativeLibrary = serviceProviderLibrary(null)) != null || + // Load from remote JAR (should work on Gradle if not air-gaped) + ((remoteJar = remoteJar()) != null && (nativeLibrary = serviceProviderLibrary(remoteJar)) != null) + ) { return nativeLibrary; } + // Load remote JAR (fallback for Gradle) throw new IllegalStateException("No NativeLibrary implementation found, please add one of the supported dependencies to your project"); } + static NativeLibrary serviceProviderLibrary(ClassLoader classLoader) { + for (NativeLibrary nativeLibrary : ServiceLoader.load( + NativeLibrary.class, classLoader == null ? Thread.currentThread().getContextClassLoader() : classLoader) + ) { + return nativeLibrary; + } + return null; + } + String getBinaryName(); default HelmLib load() { @@ -45,4 +64,5 @@ default Path createTempDirectory() { throw new IllegalStateException("Unable to create temporary directory", exception); } } + } diff --git a/lib/api/src/main/java/com/marcnuri/helm/jni/RemoteJarLoader.java b/lib/api/src/main/java/com/marcnuri/helm/jni/RemoteJarLoader.java new file mode 100644 index 0000000..ad00010 --- /dev/null +++ b/lib/api/src/main/java/com/marcnuri/helm/jni/RemoteJarLoader.java @@ -0,0 +1,127 @@ +package com.marcnuri.helm.jni; + +import org.w3c.dom.Document; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.ReadableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Properties; + +class RemoteJarLoader { + + private static final String TEMP_DIR = "java.io.tmpdir"; + private static final String VERSION = "com.marcnuri.jkube-helm.version"; + private static final String GROUP_ID = "com.marcnuri.jkube-helm.groupId"; + private static final String SNAPSHOTS = "com.marcnuri.jkube-helm.repository.snapshots"; + private static final String RELEASES = "com.marcnuri.jkube-helm.repository.releases"; + // For testing + private static final String FORCE_UPDATE = "com.marcnuri.jkube-helm.forceUpdate"; + private static final String SKIP = "com.marcnuri.jkube-helm.skipRemoteJar"; + + private RemoteJarLoader() { + } + + static ClassLoader remoteJar() { + // For testing + if (System.getProperty(SKIP) != null) { + return null; + } + final Properties nativeLibraries = new Properties(); + try (final InputStream stream = NativeLibrary.class.getResourceAsStream("/META-INF/native-libraries.properties")) { + nativeLibraries.load(stream); + final String version; + // Version can be overridden (for testing) using a system property + if (System.getProperty(VERSION) != null) { + version = System.getProperty(VERSION); + } else { + version = nativeLibraries.getProperty(VERSION); + } + final String groupId = nativeLibraries.getProperty(GROUP_ID); + final boolean isSnapshot = version.endsWith("-SNAPSHOT"); + final String repository = isSnapshot ? + nativeLibraries.getProperty(SNAPSHOTS) : + nativeLibraries.getProperty(RELEASES); + final String osName = osName(); + final String archName = archName(); + if (osName != null && archName != null) { + final String groupUrl = repository + "/" + groupId.replace('.', '/') + "/" + osName + "-" + archName + "/" + version; + final String jarName; + if (isSnapshot) { + jarName = latestSnapshot(version, osName, archName, groupUrl); + } else { + jarName = osName + "-" + archName + "-" + version + ".jar"; + } + final URL jarUrl = new URL(groupUrl + "/" + jarName); + final Path jarFile = Paths.get(System.getProperty(TEMP_DIR), jarName); + return new URLClassLoader(new URL[]{cache(jarUrl, jarFile)}); + } + } catch (IOException | ParserConfigurationException | SAXException | XPathExpressionException exception) { + // NO OP + } + return null; + } + + private static String latestSnapshot(String version, String osName, String archName, String groupUrl) + throws IOException, ParserConfigurationException, SAXException, XPathExpressionException { + final String metadataXml = groupUrl + "/maven-metadata.xml"; + final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + final Document xml = factory.newDocumentBuilder().parse(metadataXml); + final XPath xPath = XPathFactory.newInstance().newXPath(); + final String versionFragment = version.replace("-SNAPSHOT", ""); + final String timestamp = xPath.evaluate("//metadata/versioning/snapshot/timestamp", xml); + final String buildNumber = xPath.evaluate("//metadata/versioning/snapshot/buildNumber", xml); + return osName + "-" + archName + "-" + versionFragment + "-" + timestamp + "-" + buildNumber + ".jar"; + } + + private static URL cache(URL jarUrl, Path jarFile) throws IOException { + if (System.getProperty(FORCE_UPDATE) != null || !jarFile.toFile().exists() || !jarFile.toFile().isFile()) { + Files.deleteIfExists(jarFile); + try ( + ReadableByteChannel inChannel = Channels.newChannel(jarUrl.openStream()); + FileOutputStream fos = new FileOutputStream(jarFile.toFile()); + FileChannel outChannel = fos.getChannel() + ) { + outChannel.transferFrom(inChannel, 0, Long.MAX_VALUE); + } + } + return jarFile.toUri().toURL(); + } + + private static String osName() { + final String osName = System.getProperty("os.name").toLowerCase(); + if (osName.contains("win")) { + return "windows"; + } else if (osName.contains("mac")) { + return "darwin"; + } else if (osName.contains("nux") || osName.contains("nix") || osName.contains("aix")) { + return "linux"; + } + return null; + } + + private static String archName() { + final String arch = System.getProperty("os.arch"); + if (arch.equals("amd64") || arch.equals("x86_64")) { + return "amd64"; + } else if (arch.equals("arch64")) { + return "arm64"; + } + return null; + } +} diff --git a/lib/api/src/main/resources-filtered/META-INF/native-libraries.properties b/lib/api/src/main/resources-filtered/META-INF/native-libraries.properties new file mode 100644 index 0000000..faf9884 --- /dev/null +++ b/lib/api/src/main/resources-filtered/META-INF/native-libraries.properties @@ -0,0 +1,4 @@ +com.marcnuri.jkube-helm.groupId=${project.groupId} +com.marcnuri.jkube-helm.version=${project.version} +com.marcnuri.jkube-helm.repository.snapshots=${com.marcnuri.jkube-helm.repository.snapshots} +com.marcnuri.jkube-helm.repository.releases=${com.marcnuri.jkube-helm.repository.snapshots} diff --git a/pom.xml b/pom.xml index 6642203..5aaa68b 100644 --- a/pom.xml +++ b/pom.xml @@ -42,7 +42,7 @@ ossrh - https://oss.sonatype.org/service/local/staging/deploy/maven2/ + https://oss.sonatype.org/service/local/staging/deploy/maven2 @@ -51,6 +51,8 @@ UTF-8 1.8 1.8 + https://oss.sonatype.org/content/repositories/snapshots + https://repo1.maven.org/maven2 3.25.1 5.14.0 5.10.1