diff --git a/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/BootstrapJar.kt b/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/BootstrapJar.kt new file mode 100644 index 0000000..13be8ad --- /dev/null +++ b/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/BootstrapJar.kt @@ -0,0 +1,68 @@ +/* + * gremlin + * + * Copyright (c) 2025 Jason Penilla + * + * 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 + * + * http://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 xyz.jpenilla.gremlin.gradle + +import org.gradle.api.file.ArchiveOperations +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.TaskProvider +import org.gradle.jvm.tasks.Jar +import org.gradle.kotlin.dsl.attributes +import javax.inject.Inject + +abstract class BootstrapJar : Jar() { + @get:InputFiles + abstract val gremlinRuntime: ConfigurableFileCollection + + @get:Inject + abstract val archiveOps: ArchiveOperations + + @get:Input + @get:Optional + abstract val mainClass: Property + + init { + manifest.attributes( + "Main-Class" to "xyz.jpenilla.gremlin.runtime.GremlinBootstrap", + ) + val runtime = gremlinRuntime.elements.map { + it.map { e -> + archiveOps.zipTree(e) + } + } + from(runtime) { + exclude("META-INF/*") + } + } + + override fun copy() { + manifest.attributes( + "Gremlin-Main-Class" to mainClass.get(), + ) + super.copy() + } + + fun nestJars(task: TaskProvider) { + from(task.flatMap { it.outputDir }) { + into("nested-jars") + } + } +} diff --git a/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/GremlinExtension.kt b/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/GremlinExtension.kt index f45b806..ddd8d5d 100644 --- a/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/GremlinExtension.kt +++ b/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/GremlinExtension.kt @@ -18,10 +18,13 @@ package xyz.jpenilla.gremlin.gradle import org.gradle.api.provider.Property +import org.gradle.api.tasks.AbstractCopyTask +import org.gradle.api.tasks.TaskProvider abstract class GremlinExtension { abstract val defaultJarRelocatorDependencies: Property - abstract val defaultGremlinRuntimeDependency: Property + abstract val addGremlinRuntimeToCompileClasspath: Property + abstract val addGremlinRuntimeToRuntimeClasspath: Property init { init() @@ -29,6 +32,18 @@ abstract class GremlinExtension { private fun init() { defaultJarRelocatorDependencies.convention(true) - defaultGremlinRuntimeDependency.convention(true) + addGremlinRuntimeToCompileClasspath.convention(true) + addGremlinRuntimeToRuntimeClasspath.convention(true) + } + + fun nestJars( + prepareNestedJars: TaskProvider, + into: TaskProvider + ) { + into.configure { + from(prepareNestedJars.flatMap { it.outputDir }) { + into("nested-jars") + } + } } } diff --git a/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/GremlinPlugin.kt b/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/GremlinPlugin.kt index e5ef115..26c5aac 100644 --- a/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/GremlinPlugin.kt +++ b/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/GremlinPlugin.kt @@ -51,6 +51,19 @@ class GremlinPlugin : Plugin { runtimeClasspathAttributes(target.objects) } + val gremlinRuntime = target.configurations.register("gremlinRuntime") { + makeResolvable() + runtimeClasspathAttributes(target.objects) + defaultDependencies { + add(target.dependencies.create(Dependencies.DEFAULT_GREMLIN_RUNTIME)) + } + } + + val nestedJars = target.configurations.register("nestedJars") { + makeResolvable() + runtimeClasspathAttributes(target.objects) + } + val writeDependencies = target.tasks.register("writeDependencies", WriteDependencySet::class) { dependencies.setFrom(runtimeDownload) relocationDependencies.setFrom(jarRelocatorRuntime) @@ -64,6 +77,17 @@ class GremlinPlugin : Plugin { } } + val prepareNestedJars = target.tasks.register("prepareNestedJars") { + this.nestedJars.setFrom(nestedJars) + } + + target.tasks.register("bootstrapJar") { + this.gremlinRuntime.from(gremlinRuntime) + archiveClassifier.convention("gremlin") + destinationDirectory.convention(target.layout.buildDirectory.dir("libs")) + nestJars(prepareNestedJars) + } + target.afterEvaluate { if (ext.defaultJarRelocatorDependencies.get()) { target.dependencies { @@ -72,9 +96,14 @@ class GremlinPlugin : Plugin { } } } - if (ext.defaultGremlinRuntimeDependency.get()) { - target.dependencies { - java.sourceSets.getByName("main").implementationConfigurationName(Dependencies.DEFAULT_GREMLIN_RUNTIME) + if (ext.addGremlinRuntimeToCompileClasspath.get()) { + target.configurations.named(java.sourceSets.getByName("main").compileClasspathConfigurationName) { + extendsFrom(gremlinRuntime.get()) + } + } + if (ext.addGremlinRuntimeToRuntimeClasspath.get()) { + target.configurations.named(java.sourceSets.getByName("main").runtimeClasspathConfigurationName) { + extendsFrom(gremlinRuntime.get()) } } diff --git a/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/PrepareNestedJars.kt b/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/PrepareNestedJars.kt new file mode 100644 index 0000000..f40bb81 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/PrepareNestedJars.kt @@ -0,0 +1,60 @@ +/* + * gremlin + * + * Copyright (c) 2025 Jason Penilla + * + * 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 + * + * http://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 xyz.jpenilla.gremlin.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import java.nio.file.Files +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.deleteRecursively +import kotlin.io.path.writeText + +@OptIn(ExperimentalPathApi::class) +abstract class PrepareNestedJars : DefaultTask() { + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @get:InputFiles + abstract val nestedJars: ConfigurableFileCollection + + init { + init() + } + + private fun init() { + outputDir.convention(project.layout.buildDirectory.dir("nested-jars/$name")) + } + + @TaskAction + fun run() { + val outputPath = outputDir.path + outputPath.deleteRecursively() + Files.createDirectories(outputPath) + + val index = mutableListOf() + nestedJars.files.forEach { jar -> + Files.copy(jar.toPath(), outputPath.resolve(jar.name)) + index += jar.name + } + outputPath.resolve("index.txt").writeText(index.joinToString("\n")) + } +} diff --git a/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/utils.kt b/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/utils.kt index 7b44963..8d8110a 100644 --- a/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/utils.kt +++ b/gradle-plugin/src/main/kotlin/xyz/jpenilla/gremlin/gradle/utils.kt @@ -17,11 +17,25 @@ */ package xyz.jpenilla.gremlin.gradle +import org.gradle.api.file.FileSystemLocationProperty import java.io.InputStream +import java.net.URI +import java.nio.file.FileSystem +import java.nio.file.FileSystems import java.nio.file.Path import java.security.MessageDigest import kotlin.io.path.inputStream +val FileSystemLocationProperty<*>.path: Path + get() = get().asFile.toPath() + +fun Path.jarUri(): URI = URI.create("jar:" + toUri().toString()) + +fun Path.openZip(op: (FileSystem) -> R): R = + FileSystems.newFileSystem(jarUri(), emptyMap()).use { fs -> + op(fs) + } + enum class HashingAlgorithm(val algorithmName: String) { SHA256("SHA-256"), SHA1("SHA-1"); diff --git a/runtime/src/main/java/xyz/jpenilla/gremlin/runtime/GremlinBootstrap.java b/runtime/src/main/java/xyz/jpenilla/gremlin/runtime/GremlinBootstrap.java new file mode 100644 index 0000000..98ed1b2 --- /dev/null +++ b/runtime/src/main/java/xyz/jpenilla/gremlin/runtime/GremlinBootstrap.java @@ -0,0 +1,203 @@ +/* + * gremlin + * + * Copyright (c) 2025 Jason Penilla + * + * 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 + * + * http://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 xyz.jpenilla.gremlin.runtime; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.logging.Logger; +import java.util.stream.Stream; +import org.jspecify.annotations.NullMarked; +import xyz.jpenilla.gremlin.runtime.logging.GremlinLogger; +import xyz.jpenilla.gremlin.runtime.logging.JavaGremlinLogger; + +@NullMarked +public final class GremlinBootstrap { + private static final String DOT_GREMLIN = ".gremlin"; + private static final String NESTED_JARS = "nested-jars"; + private static final String NESTED_JARS_INDEX = NESTED_JARS + "/index.txt"; + private static final String DEPENDENCY_CACHE = "dependency-cache"; + private static final String GREMLIN_MAIN_CLASS = "Gremlin-Main-Class"; + + public GremlinBootstrap() { + } + + public static void main(final String[] args) { + final List jars = new ArrayList<>( + extractNestedJars(Path.of(DOT_GREMLIN + "/" + NESTED_JARS)) + ); + + final DependencySet dependencies = getDependencies(jars); + final DependencyCache cache = new DependencyCache(Path.of(DOT_GREMLIN + "/" + DEPENDENCY_CACHE)); + final GremlinLogger logger = new JavaGremlinLogger(Logger.getLogger(GremlinBootstrap.class.getName())); + try (final DependencyResolver resolver = new DependencyResolver(logger)) { + jars.addAll(resolver.resolve(dependencies, cache).jarFiles()); + } + + final String mainClassName = getMainClassName(); + final ClassLoader loader = buildClassLoader(jars); + + final Thread applicationThread = new Thread(() -> { + final Method main = getMainMethod(mainClassName, loader); + try { + main.invoke(null, (Object) args); + } catch (final IllegalAccessException e) { + throw new RuntimeException("Failed to invoke main method of class: " + mainClassName, e); + } catch (final InvocationTargetException e) { + throw new RuntimeException("Uncaught exception during application execution", e.getCause()); + } + }, "bootstrapped-main"); + applicationThread.setContextClassLoader(loader); + applicationThread.start(); + + try { + cache.cleanup(); + } catch (final Throwable e) { + //noinspection CallToPrintStackTrace + new RuntimeException("Exception cleaning up dependency cache", e).printStackTrace(); + } + } + + private static Method getMainMethod(final String mainClassName, final ClassLoader loader) { + final Class mainClass; + try { + mainClass = Class.forName(mainClassName, true, loader); + } catch (final ClassNotFoundException e) { + throw new RuntimeException("Main class not found: " + mainClassName, e); + } + try { + return mainClass.getMethod("main", String[].class); + } catch (final ReflectiveOperationException e) { + throw new RuntimeException("Main method not found in class: " + mainClassName, e); + } + } + + private static String getMainClassName() { + final String mainClassName; + try (final InputStream manifestStream = GremlinBootstrap.class.getClassLoader().getResourceAsStream(JarFile.MANIFEST_NAME)) { + if (manifestStream == null) { + throw new IllegalStateException("Could not find " + JarFile.MANIFEST_NAME + " in classpath"); + } + final Manifest manifest = new Manifest(manifestStream); + mainClassName = manifest.getMainAttributes().getValue(GREMLIN_MAIN_CLASS); + if (mainClassName == null || mainClassName.isEmpty()) { + throw new IllegalStateException(GREMLIN_MAIN_CLASS + " not specified in manifest"); + } + } catch (final IOException e) { + throw new RuntimeException("Failed to read " + JarFile.MANIFEST_NAME, e); + } + return mainClassName; + } + + private static DependencySet getDependencies(final List nestedJars) { + DependencySet dependencies = null; + for (final Path jar : nestedJars) { + try (final JarFile jarFile = new JarFile(jar.toFile())) { + final JarEntry entry = jarFile.getJarEntry("dependencies.txt"); + if (entry != null) { + try (final InputStream inputStream = jarFile.getInputStream(entry)) { + dependencies = DependencySet.read(inputStream); + } + break; + } + } catch (final Exception e) { + throw new RuntimeException("Failed to read dependencies.txt from jar: " + jar, e); + } + } + if (dependencies == null) { + throw new IllegalStateException("No dependencies.txt found in nested jars"); + } + return dependencies; + } + + private static ClassLoader buildClassLoader(final List jars) { + final URL[] classpath = jars.stream() + .map(Path::toUri) + .map(uri -> { + try { + return uri.toURL(); + } catch (final Exception e) { + throw new RuntimeException("Failed to convert path to URL: " + uri, e); + } + }) + .toArray(URL[]::new); + + return new URLClassLoader(classpath, GremlinBootstrap.class.getClassLoader()); + } + + private static List extractNestedJars(final Path into) { + final InputStream nestedJarsIndexStream = GremlinBootstrap.class.getClassLoader().getResourceAsStream(NESTED_JARS_INDEX); + if (nestedJarsIndexStream == null) { + throw new IllegalStateException("Could not find " + NESTED_JARS_INDEX + " in classpath"); + } + final Set nestedJarsPaths = new LinkedHashSet<>(); + try (nestedJarsIndexStream) { + final String indexContent = new String(nestedJarsIndexStream.readAllBytes(), StandardCharsets.UTF_8); + for (final String line : indexContent.split("\n")) { + nestedJarsPaths.add(line.trim()); + } + } catch (final Exception e) { + throw new RuntimeException("Failed to read " + NESTED_JARS_INDEX, e); + } + + final List paths = new ArrayList<>(); + for (final String path : nestedJarsPaths) { + final InputStream resourceStream = GremlinBootstrap.class.getClassLoader().getResourceAsStream("nested-jars/" + path); + if (resourceStream == null) { + throw new IllegalStateException("Could not find nested jar: " + path); + } + final Path outputPath = into.resolve(path); + try { + Files.createDirectories(outputPath.getParent()); + try (resourceStream) { + Files.copy(resourceStream, outputPath, StandardCopyOption.REPLACE_EXISTING); + } + paths.add(outputPath); + } catch (final Exception e) { + throw new RuntimeException("Failed to extract nested jar: " + path, e); + } + } + + try (final Stream s = Files.list(into)) { + for (final Path path : s.toList()) { + if (Files.isRegularFile(path) && !nestedJarsPaths.contains(path.getFileName().toString())) { + // Delete no longer needed nested jars + Files.delete(path); + } + } + } catch (final IOException e) { + throw new RuntimeException("Failed to clean up nested jars directory: " + into, e); + } + + return paths; + } +}